tools/make_synthetic_golden.py (5908 bytes)
1 """Generate golden fixtures for languages that have no recorded transcript. 2 3 make_golden.py needs a real Whisper transcript. This script makes one: it 4 takes a public-domain text, cuts it into segments the size of Whisper's, and 5 adds the kinds of error a small speech model makes (a dropped word, a wrong 6 word, lost punctuation, a hallucinated line). The reference implementation 7 then aligns that transcript, and every stage is recorded as make_golden.py 8 records it. The same fixture file is read by the Kotlin tests and by the 9 browser engine tests of subplz-web. 10 11 The transcript is deterministic: the seed is derived from the fixture name. 12 13 Needs the subplz environment, like make_golden.py: 14 15 SubPlz/venv311/Scripts/python tools/make_synthetic_golden.py \ 16 --text dom_casmurro.txt --language pt --name pt_casmurro \ 17 --skip 40 --chars 5000 --out core/src/test/resources/golden \ 18 --out ../subplz-web/tests/engine/golden 19 20 The text is a Project Gutenberg plain-text file: the header and the licence 21 are cut at the START and END markers. 22 """ 23 24 from __future__ import annotations 25 26 import argparse 27 import json 28 import random 29 import re 30 import sys 31 import zlib 32 from pathlib import Path 33 34 sys.path.insert(0, str(Path(__file__).resolve().parent)) 35 from make_golden import run_case # noqa: E402 36 37 # Marks that a speech model never writes. 38 _SILENT = "«»„“”‘’—–\"'()[]" 39 # Marks that a speech model writes only sometimes. 40 _SOMETIMES = ".,;:!?¿¡…" 41 42 43 def gutenberg_paragraphs(path: Path) -> list[str]: 44 raw = path.read_text(encoding="utf-8-sig") 45 lines = raw.replace("\r\n", "\n").split("\n") 46 start = next((i for i, l in enumerate(lines) if l.startswith("*** START")), -1) + 1 47 end = next((i for i, l in enumerate(lines) if l.startswith("*** END")), len(lines)) 48 out, current = [], [] 49 for line in lines[start:end]: 50 if line.strip(): 51 current.append(line.strip()) 52 elif current: 53 out.append(" ".join(current)) 54 current = [] 55 if current: 56 out.append(" ".join(current)) 57 return out 58 59 60 def prose_only(paragraphs: list[str]) -> list[str]: 61 """Drop headings and tables of contents: short lines in capitals, or without a full stop.""" 62 return [p for p in paragraphs if len(p) > 60 and not p.isupper()] 63 64 65 def garble(word: str, rng: random.Random, vocabulary: list[str]) -> str: 66 kind = rng.random() 67 letters = [c for c in word if c.isalpha()] 68 if kind < 0.4 and len(letters) > 3: 69 # One letter wrong, as in a mishearing. 70 at = rng.randrange(len(word)) 71 return word[:at] + rng.choice("aeiou") + word[at + 1:] 72 if kind < 0.7 and len(word) > 3: 73 # A letter lost. 74 at = rng.randrange(len(word)) 75 return word[:at] + word[at + 1:] 76 # A different word of the same text. 77 return rng.choice(vocabulary) 78 79 80 def transcribe(paragraphs: list[str], rng: random.Random) -> list[dict]: 81 """Segments the size of Whisper's, with its errors, and times at a reading speed.""" 82 words = " ".join(paragraphs).split() 83 vocabulary = [w.strip(_SILENT + _SOMETIMES) for w in words if w.strip(_SILENT + _SOMETIMES)] 84 segments = [] 85 at = 0.0 86 i = 0 87 while i < len(words): 88 want = rng.randint(25, 75) 89 chunk = [] 90 length = 0 91 while i < len(words) and (length < want or not chunk): 92 w = words[i] 93 i += 1 94 length += len(w) + 1 95 r = rng.random() 96 if r < 0.03: 97 continue # a word lost 98 if r < 0.10: 99 w = garble(w, rng, vocabulary) # a word wrong 100 w = "".join(c for c in w if c not in _SILENT) 101 if w and w[-1] in _SOMETIMES and rng.random() < 0.5: 102 w = w.rstrip(_SOMETIMES) 103 w = w.lstrip("¿¡") 104 if w: 105 chunk.append(w) 106 text = " ".join(chunk) 107 if not text: 108 continue 109 seconds = len(text) / rng.uniform(12.0, 18.0) 110 segments.append({"text": text, "start": round(at, 3), "end": round(at + seconds, 3)}) 111 at += seconds + rng.uniform(0.0, 0.6) 112 # A hallucinated line at the start, as Whisper makes on silence. 113 lead = {"text": rng.choice(vocabulary).capitalize() + ".", "start": 0.0, "end": 1.2} 114 return [lead] + [{**s, "start": round(s["start"] + 1.5, 3), "end": round(s["end"] + 1.5, 3)} 115 for s in segments] 116 117 118 def main() -> None: 119 sys.stdout.reconfigure(encoding="utf-8") 120 ap = argparse.ArgumentParser() 121 ap.add_argument("--text", type=Path, required=True, help="Project Gutenberg plain text") 122 ap.add_argument("--language", required=True, help="Whisper language code") 123 ap.add_argument("--name", required=True, help="fixture name, also the random seed") 124 ap.add_argument("--skip", type=int, default=0, help="prose paragraphs to skip first") 125 ap.add_argument("--chars", type=int, default=5000, help="characters of text to keep") 126 ap.add_argument("--out", type=Path, action="append", required=True, help="output directory (repeatable)") 127 args = ap.parse_args() 128 129 paragraphs = prose_only(gutenberg_paragraphs(args.text))[args.skip:] 130 kept, total = [], 0 131 for p in paragraphs: 132 if total >= args.chars: 133 break 134 kept.append(p) 135 total += len(p) 136 if not kept: 137 sys.exit("no paragraphs left after --skip") 138 139 rng = random.Random(zlib.crc32(args.name.encode("utf-8"))) 140 segments = transcribe(kept, rng) 141 case = run_case(args.name, args.language, segments, kept) 142 case["synthetic"] = True 143 for out in args.out: 144 out.mkdir(parents=True, exist_ok=True) 145 (out / f"{args.name}.json").write_text( 146 json.dumps(case, ensure_ascii=False, indent=0), encoding="utf-8") 147 print(f"wrote {out / (args.name + '.json')}") 148 149 150 if __name__ == "__main__": 151 main()