Recently Written · git

subread-android

SubRead for Android: times an audiobook against its ebook on the device

git clone https://github.com/equwal/subread-android

Log | Files | Refs


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()