explainer-studio 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. explainer_studio-0.1.5.dist-info/METADATA +149 -0
  2. explainer_studio-0.1.5.dist-info/RECORD +37 -0
  3. explainer_studio-0.1.5.dist-info/WHEEL +4 -0
  4. explainer_studio-0.1.5.dist-info/entry_points.txt +2 -0
  5. explainer_studio-0.1.5.dist-info/licenses/LICENSE +41 -0
  6. studio/__init__.py +0 -0
  7. studio/build.py +196 -0
  8. studio/chatterbox_worker.py +96 -0
  9. studio/cli.py +265 -0
  10. studio/common.py +160 -0
  11. studio/data/brand/brand.css +145 -0
  12. studio/data/brand/fonts/JetBrainsMono.ttf +0 -0
  13. studio/data/brand/fonts/OFL-JetBrainsMono.txt +93 -0
  14. studio/data/brand/fonts/OFL-PTSerif.txt +93 -0
  15. studio/data/brand/fonts/OFL-SourceSans3.txt +93 -0
  16. studio/data/brand/fonts/PTSerif-Bold.ttf +0 -0
  17. studio/data/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
  18. studio/data/brand/fonts/PTSerif-Italic.ttf +0 -0
  19. studio/data/brand/fonts/PTSerif-Regular.ttf +0 -0
  20. studio/data/brand/fonts/SourceSans3-Bold.ttf +0 -0
  21. studio/data/brand/fonts/SourceSans3-Italic.ttf +0 -0
  22. studio/data/brand/fonts/SourceSans3.ttf +0 -0
  23. studio/data/brand/frame.md +108 -0
  24. studio/data/brand/pronunciations.yaml +8 -0
  25. studio/data/brand/vendor/gsap.min.js +11 -0
  26. studio/data/templates/scenes/blank.html +46 -0
  27. studio/data/templates/scenes/outro.html +50 -0
  28. studio/data/templates/scenes/points.html +55 -0
  29. studio/data/templates/scenes/quote.html +41 -0
  30. studio/data/templates/scenes/stat.html +57 -0
  31. studio/data/templates/scenes/title.html +46 -0
  32. studio/data/templates/script.md +51 -0
  33. studio/data/templates/thumbnail.html +28 -0
  34. studio/music.py +66 -0
  35. studio/render.py +271 -0
  36. studio/script.py +158 -0
  37. studio/voice.py +633 -0
studio/voice.py ADDED
@@ -0,0 +1,633 @@
1
+ """Narration: TTS (Kokoro in-process, Chatterbox in a worker, or ElevenLabs) or a recorded take, then word timings by ASR + alignment.
2
+
3
+ Per scene this writes, inside the episode:
4
+ audio/tts/<hash>.wav cached TTS chunk (48 kHz mono); unchanged text is never re-synthesised
5
+ audio/vo/<id>.wav the scene's narration (chunks joined with their gaps)
6
+ audio/vo/<id>.words.json word timings for the *script* words (spelling as written)
7
+
8
+ To use a real recording for a scene, drop audio/recorded/<id>.(wav|flac|m4a|mp3) into the
9
+ episode. It replaces the TTS for that scene and is aligned to the script text the same way.
10
+
11
+ The engine is the script's `tts:` setting. ElevenLabs needs ELEVENLABS_API_KEY (environment or
12
+ the studio's .env); each chunk is sent with its neighbours as context so prosody carries across.
13
+ Chatterbox pins torch and numpy<2, so it runs in its own venv (built on first use) as a
14
+ subprocess worker (chatterbox_worker.py), cloning the voice from a reference clip.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import atexit
20
+ import difflib
21
+ import functools
22
+ import importlib
23
+ import json
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ import urllib.error
32
+ import urllib.request
33
+ import zipfile
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ import soundfile as sf
38
+ import yaml
39
+
40
+ from .common import BRAND, CHATTERBOX_VERSION, ROOT, SAMPLE_RATE, die, hf, hf_env, info, load_env, run, sha
41
+ from .script import Scene, Script
42
+
43
+ KOKORO_DIR = Path.home() / ".cache" / "hyperframes" / "tts"
44
+ CHATTERBOX_DIR = Path.home() / ".cache" / "hyperframes" / "chatterbox" / CHATTERBOX_VERSION
45
+ RECORDED_EXT = (".wav", ".flac", ".m4a", ".mp3", ".ogg")
46
+ # Kokoro voice-id prefix -> phonemizer language (af_heart -> en-us, bm_george -> en-gb, ...)
47
+ LANGS = {"a": "en-us", "b": "en-gb", "e": "es", "f": "fr-fr", "h": "hi", "i": "it", "p": "pt-br", "j": "ja", "z": "zh"}
48
+
49
+
50
+ # ---------------------------------------------------------------- pronunciation
51
+
52
+ def load_pronunciations(script: Script) -> dict[str, str]:
53
+ table: dict[str, str] = {}
54
+ global_file = BRAND / "pronunciations.yaml"
55
+ if global_file.exists():
56
+ table.update(yaml.safe_load(global_file.read_text()) or {})
57
+ table.update(script.meta.get("pronounce") or {})
58
+ return table
59
+
60
+
61
+ def respell(text: str, table: dict[str, str]) -> str:
62
+ for word, say in sorted(table.items(), key=lambda kv: -len(kv[0])):
63
+ text = re.sub(rf"(?<![\w-]){re.escape(word)}(?![\w-])", str(say), text)
64
+ return text
65
+
66
+
67
+ # ---------------------------------------------------------------- TTS
68
+
69
+ # Gentle finishing for dry TTS: rumble cut, a little warmth and presence, soft de-ess and
70
+ # compression, and a faint short room so it sounds spoken into a space rather than generated.
71
+ VOICE_FX = ",".join([
72
+ "highpass=f=70",
73
+ "lowshelf=f=180:g=1.5",
74
+ "equalizer=f=3200:t=q:w=1.2:g=1.2",
75
+ "deesser=i=0.25:m=0.5:f=0.5",
76
+ "acompressor=threshold=-20dB:ratio=2:attack=8:release=150:makeup=1.5",
77
+ "aecho=1.0:1.0:11|19|29:0.05|0.035|0.02",
78
+ ])
79
+ SENTENCE_END = re.compile(r"(?<=[.!?…])\s+")
80
+ CLAUSE_END = re.compile(r"(?<=[,;:—])\s+")
81
+ MIN_UTTERANCE = 40 # phonemes; Kokoro garbles very short inputs, so tiny sentences ride with the next
82
+
83
+
84
+ def parse_voice(spec: str) -> list[tuple[str, float]]:
85
+ """'bf_emma*0.6 + bf_isabella*0.4' (or 'a + b', or just 'a') -> normalised (name, weight) pairs."""
86
+ parts = []
87
+ for term in spec.split("+"):
88
+ name, _, weight = term.strip().partition("*")
89
+ if not name.strip():
90
+ die(f"bad voice '{spec}' (expected e.g. 'bf_emma*0.6 + bf_isabella*0.4')")
91
+ try:
92
+ parts.append((name.strip(), float(weight) if weight.strip() else 1.0))
93
+ except ValueError:
94
+ die(f"bad voice weight in '{spec}'")
95
+ total = sum(w for _, w in parts)
96
+ if total <= 0:
97
+ die(f"voice weights in '{spec}' must add up to more than 0")
98
+ return [(n, w / total) for n, w in parts]
99
+
100
+
101
+ def voice_spec(spec: str) -> str:
102
+ """Canonical spelling of a voice blend, for cache keys and file names."""
103
+ blend = parse_voice(spec)
104
+ if len(blend) == 1:
105
+ return blend[0][0]
106
+ return " + ".join(f"{n}*{w:.3f}".rstrip("0").rstrip(".") for n, w in blend)
107
+
108
+
109
+ SPACY_MODEL = "en_core_web_sm-3.8.0" # matches the spacy>=3.8,<3.9 pin in pyproject.toml
110
+ SPACY_MODEL_URL = f"https://github.com/explosion/spacy-models/releases/download/{SPACY_MODEL}/{SPACY_MODEL}-py3-none-any.whl"
111
+
112
+
113
+ def ensure_spacy_model() -> None:
114
+ """Make en_core_web_sm importable for misaki, unpacking its wheel into the cache on first use.
115
+
116
+ It can't be a dependency (PyPI rejects direct-URL requirements), and misaki's own fallback,
117
+ `spacy download`, needs pip, which uv environments don't have.
118
+ """
119
+ import spacy.util
120
+
121
+ if spacy.util.is_package("en_core_web_sm"):
122
+ return
123
+ dst = Path.home() / ".cache" / "hyperframes" / "spacy" / SPACY_MODEL
124
+ if not dst.exists():
125
+ info(f" downloading spaCy model {SPACY_MODEL} (~13 MB, once)")
126
+ dst.parent.mkdir(parents=True, exist_ok=True)
127
+ with tempfile.TemporaryDirectory(dir=dst.parent) as tmp:
128
+ whl = Path(tmp) / "model.whl"
129
+ urllib.request.urlretrieve(SPACY_MODEL_URL, whl)
130
+ with zipfile.ZipFile(whl) as z:
131
+ z.extractall(Path(tmp) / "pkg")
132
+ (Path(tmp) / "pkg").rename(dst)
133
+ sys.path.insert(0, str(dst))
134
+ importlib.invalidate_caches()
135
+
136
+
137
+ def make_g2p(name: str, lang: str):
138
+ """misaki (the G2P Kokoro was trained with) for English; None means kokoro-onnx's espeak."""
139
+ if name == "espeak" or lang not in ("en-us", "en-gb"):
140
+ return None
141
+ if name != "misaki":
142
+ die(f"unknown g2p '{name}' (choose: misaki, espeak)")
143
+ try:
144
+ ensure_spacy_model()
145
+ from misaki import en, espeak
146
+ british = lang == "en-gb"
147
+ return en.G2P(trf=False, british=british, fallback=espeak.EspeakFallback(british=british))
148
+ except Exception as e: # missing package or spaCy model
149
+ info(f" warning: misaki unavailable ({e}); falling back to espeak phonemes")
150
+ return None
151
+
152
+
153
+ def sentence_groups(phonemes: str) -> list[str]:
154
+ """Split phonemes at sentence ends, merging short sentences forward."""
155
+ groups: list[str] = []
156
+ pending = ""
157
+ for sent in SENTENCE_END.split(" ".join(phonemes.split())):
158
+ pending = f"{pending} {sent}".strip()
159
+ if len(pending) >= MIN_UTTERANCE:
160
+ groups.append(pending)
161
+ pending = ""
162
+ if pending:
163
+ if groups:
164
+ groups[-1] += " " + pending
165
+ else:
166
+ groups.append(pending)
167
+ return groups
168
+
169
+
170
+ class Kokoro:
171
+ """Kokoro in-process: blended voice, misaki G2P, one utterance per sentence group."""
172
+
173
+ def __init__(self, script: Script):
174
+ self.speed = float(script.setting("speed"))
175
+ self.sentence_pause = float(script.setting("sentence_pause"))
176
+ self.clause_pause = float(script.setting("clause_pause"))
177
+ self.fx = bool(script.setting("voice_fx"))
178
+ self.chars, self.seconds = 0, 0.0
179
+ model = KOKORO_DIR / "models" / "kokoro-v1.0.onnx"
180
+ voices = KOKORO_DIR / "voices" / "voices-v1.0.bin"
181
+ if not (model.exists() and voices.exists()):
182
+ info(" downloading Kokoro model via the HyperFrames CLI (first run only)...")
183
+ with tempfile.TemporaryDirectory() as tmp:
184
+ run(hf("tts", "warm up", "-o", f"{tmp}/x.wav"), env=hf_env())
185
+ import kokoro_onnx
186
+ self.model = kokoro_onnx.Kokoro(str(model), str(voices))
187
+ blend = parse_voice(str(script.setting("voice")))
188
+ unknown = [n for n, _ in blend if n not in self.model.get_voices()]
189
+ if unknown:
190
+ die(f"unknown Kokoro voice {', '.join(unknown)} (choose from: {', '.join(self.model.get_voices())})")
191
+ self.style = sum(w * self.model.get_voice_style(n) for n, w in blend).astype(np.float32)
192
+ self.lang = LANGS.get(blend[0][0][:1], "en-us")
193
+ self.g2p = make_g2p(str(script.setting("g2p")), self.lang)
194
+
195
+ @staticmethod
196
+ def cache_tag(script: Script) -> tuple:
197
+ return ("kokoro-v1.0", voice_spec(str(script.setting("voice"))), float(script.setting("speed")),
198
+ str(script.setting("g2p")), float(script.setting("sentence_pause")),
199
+ float(script.setting("clause_pause")), bool(script.setting("voice_fx")) and VOICE_FX)
200
+
201
+ def phonemize(self, text: str) -> str:
202
+ if self.g2p:
203
+ return self.g2p(text)[0]
204
+ return self.model.tokenizer.phonemize(text, self.lang)
205
+
206
+ def synth(self, text: str) -> tuple[np.ndarray, int]:
207
+ """Each sentence group is its own utterance (clean final intonation), joined by sentence_pause."""
208
+ parts, rate = [], 24000
209
+ for group in sentence_groups(self.phonemize(text)):
210
+ samples, rate = self.model.create(group, voice=self.style, speed=self.speed, lang=self.lang,
211
+ is_phonemes=True, trim=True, sentence_pause=self.sentence_pause,
212
+ clause_pause=self.clause_pause)
213
+ parts.append((np.asarray(samples, dtype=np.float32), self.sentence_pause))
214
+ return join_parts(parts, rate), rate
215
+
216
+ def synth_to(self, text: str, dst: Path, prev_text: str = "", next_text: str = "") -> None:
217
+ finish(*self.synth(text), dst, self.fx)
218
+ self.chars += len(text)
219
+
220
+
221
+ class ElevenLabs:
222
+ """ElevenLabs text-to-speech over HTTPS (stdlib only)."""
223
+
224
+ URL = "https://api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_44100_128"
225
+
226
+ def __init__(self, script: Script):
227
+ self.key = elevenlabs_key()
228
+ _, self.voice, self.model, self.settings = self.cache_tag(script)
229
+ self.chars, self.seconds = 0, 0.0
230
+
231
+ @staticmethod
232
+ def cache_tag(script: Script) -> tuple:
233
+ settings = {**(script.setting("voice_settings") or {}), "speed": float(script.setting("speed"))}
234
+ return ("elevenlabs", str(script.setting("voice")), str(script.setting("tts_model")), settings)
235
+
236
+ def synth_to(self, text: str, dst: Path, prev_text: str = "", next_text: str = "") -> None:
237
+ body = {"text": text, "model_id": self.model, "voice_settings": self.settings}
238
+ if prev_text:
239
+ body["previous_text"] = prev_text
240
+ if next_text:
241
+ body["next_text"] = next_text
242
+ audio = elevenlabs_post(self.URL.format(voice=self.voice), self.key, body)
243
+ raw = dst.with_name(f".{dst.stem}.raw.mp3")
244
+ raw.write_bytes(audio)
245
+ to_48k_mono(raw, dst)
246
+ raw.unlink()
247
+ self.chars += len(text)
248
+
249
+
250
+ class Chatterbox:
251
+ """Chatterbox (Resemble AI) on the CPU, in its own venv: voice cloned from a reference clip."""
252
+
253
+ WORKER = Path(__file__).with_name("chatterbox_worker.py")
254
+ MAX_CHARS = 280 # Chatterbox drifts on long inputs, so sentences are grouped up to about this length
255
+
256
+ def __init__(self, script: Script):
257
+ self.model = chatterbox_model(script)
258
+ ref = chatterbox_ref(script)
259
+ self.ref = str(ref) if ref else None
260
+ self.sentence_pause = float(script.setting("sentence_pause"))
261
+ self.clause_pause = float(script.setting("clause_pause"))
262
+ self.fx = bool(script.setting("voice_fx"))
263
+ self.params = {k: script.setting(k) for k in ("exaggeration", "cfg_weight", "temperature", "seed")}
264
+ self.chars, self.seconds = 0, 0.0
265
+ python = self.ensure_env()
266
+ info(f" starting Chatterbox {self.model} on the CPU (first run downloads the model)...")
267
+ self.proc = subprocess.Popen([str(python), "-I", str(self.WORKER), self.model],
268
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
269
+ atexit.register(self.close)
270
+ self.request(None) # wait for the model to load
271
+
272
+ @staticmethod
273
+ def ensure_env() -> Path:
274
+ python, stamp = CHATTERBOX_DIR / "bin" / "python", CHATTERBOX_DIR / ".installed"
275
+ # resemble-perth (the watermarker) still imports pkg_resources, which setuptools 81 dropped.
276
+ reqs = [f"chatterbox-tts=={CHATTERBOX_VERSION}", "soundfile", "setuptools<81"]
277
+ if stamp.exists() and stamp.read_text().split() == reqs:
278
+ return python
279
+ if not shutil.which("uv"):
280
+ die("tts: chatterbox needs uv on PATH to build its environment")
281
+ info(f" installing chatterbox-tts {CHATTERBOX_VERSION} with CPU torch into {CHATTERBOX_DIR} (first run only)...")
282
+ run(["uv", "venv", "--clear", "--python", "3.12", str(CHATTERBOX_DIR)])
283
+ run(["uv", "pip", "install", "--python", str(python), "--torch-backend", "cpu", *reqs])
284
+ stamp.write_text("\n".join(reqs))
285
+ return python
286
+
287
+ @staticmethod
288
+ def cache_tag(script: Script) -> tuple:
289
+ model = chatterbox_model(script)
290
+ ref = chatterbox_ref(script)
291
+ params = [float(script.setting(k)) for k in ("temperature", "seed")]
292
+ if model == "standard":
293
+ params += [float(script.setting(k)) for k in ("exaggeration", "cfg_weight")]
294
+ return ("chatterbox", model, clip_hash(ref) if ref else "default", CHATTERBOX_VERSION, *params,
295
+ float(script.setting("sentence_pause")), float(script.setting("clause_pause")),
296
+ bool(script.setting("voice_fx")) and VOICE_FX)
297
+
298
+ def request(self, req: dict | None) -> dict:
299
+ if req is not None:
300
+ self.proc.stdin.write(json.dumps(req) + "\n")
301
+ self.proc.stdin.flush()
302
+ line = self.proc.stdout.readline()
303
+ if not line:
304
+ die("Chatterbox worker exited (see the error above)")
305
+ try:
306
+ reply = json.loads(line)
307
+ except json.JSONDecodeError:
308
+ die(f"Chatterbox worker sent a non-JSON reply: {line[:200]!r}")
309
+ if "error" in reply:
310
+ die(f"Chatterbox: {reply['error']}")
311
+ return reply
312
+
313
+ def synth_to(self, text: str, dst: Path, prev_text: str = "", next_text: str = "") -> None:
314
+ parts, rate = [], 24000
315
+ with tempfile.TemporaryDirectory() as tmp:
316
+ for i, (group, whole) in enumerate(text_groups(text, self.MAX_CHARS)):
317
+ wav = Path(tmp) / f"{i}.wav"
318
+ self.request({"text": group, "out": str(wav), "ref": self.ref, **self.params})
319
+ samples, rate = sf.read(str(wav), dtype="float32")
320
+ parts.append((trim_silence(samples, rate), self.sentence_pause if whole else self.clause_pause))
321
+ finish(join_parts(parts, rate), rate, dst, self.fx)
322
+ self.chars += len(text)
323
+
324
+ def close(self) -> None:
325
+ if self.proc.poll() is None:
326
+ self.proc.stdin.close()
327
+ try:
328
+ self.proc.wait(timeout=10)
329
+ except subprocess.TimeoutExpired:
330
+ self.proc.kill()
331
+
332
+
333
+ def chatterbox_model(script: Script) -> str:
334
+ model = str(script.setting("chatterbox_model")).lower()
335
+ if model not in ("turbo", "standard"):
336
+ die(f"unknown chatterbox_model '{model}' (choose: turbo, standard)")
337
+ return model
338
+
339
+
340
+ def clip_hash(path: Path) -> str:
341
+ """sha() of a reference clip, read once per sync rather than once per chunk."""
342
+ st = path.stat()
343
+ return _clip_hash(path, st.st_mtime_ns, st.st_size)
344
+
345
+
346
+ @functools.lru_cache(maxsize=16)
347
+ def _clip_hash(path: Path, mtime_ns: int, size: int) -> str:
348
+ return sha(path)
349
+
350
+
351
+ def chatterbox_ref(script: Script) -> Path | None:
352
+ """The reference clip named by `voice:` (relative to the studio root), or None for the built-in voice."""
353
+ spec = str(script.meta.get("voice") or "default").strip()
354
+ if spec == "default":
355
+ return None
356
+ path = Path(spec) if Path(spec).is_absolute() else ROOT / spec
357
+ if not path.exists():
358
+ die(f"Chatterbox voice clip not found: {path} (voice: is a 5-15s WAV, or 'default')")
359
+ return path
360
+
361
+
362
+ def text_groups(text: str, max_chars: int) -> list[tuple[str, bool]]:
363
+ """Split text into groups of up to max_chars: whole sentences where they fit, else a long sentence's
364
+ clauses (then words). Each group comes with whether it ends a sentence, for the pause after it."""
365
+ groups: list[tuple[str, bool]] = []
366
+ for sent in SENTENCE_END.split(" ".join(text.split())):
367
+ if len(sent) > max_chars:
368
+ pieces = pack(CLAUSE_END.split(sent), max_chars)
369
+ pieces = [w for p in pieces for w in (pack(p.split(), max_chars) if len(p) > max_chars else [p])]
370
+ groups += [(p, False) for p in pieces[:-1]] + [(pieces[-1], True)]
371
+ elif groups and groups[-1][1] and len(groups[-1][0]) + 1 + len(sent) <= max_chars:
372
+ groups[-1] = (groups[-1][0] + " " + sent, True)
373
+ else:
374
+ groups.append((sent, True))
375
+ return groups
376
+
377
+
378
+ def pack(pieces: list[str], max_chars: int) -> list[str]:
379
+ """Join consecutive pieces with spaces into runs of up to max_chars."""
380
+ runs: list[str] = []
381
+ for piece in pieces:
382
+ if runs and len(runs[-1]) + 1 + len(piece) <= max_chars:
383
+ runs[-1] += " " + piece
384
+ else:
385
+ runs.append(piece)
386
+ return runs
387
+
388
+
389
+ def join_parts(parts: list[tuple[np.ndarray, float]], rate: int) -> np.ndarray:
390
+ """Concatenate (samples, pause after) parts with that much silence between them."""
391
+ out = []
392
+ for i, (samples, pause) in enumerate(parts):
393
+ out.append(samples)
394
+ if i < len(parts) - 1:
395
+ out.append(np.zeros(int(pause * rate), dtype=np.float32))
396
+ return np.concatenate(out)
397
+
398
+
399
+ def finish(samples: np.ndarray, rate: int, dst: Path, fx: bool) -> None:
400
+ """The shared voice chain: resample to 48 kHz mono, with VOICE_FX if enabled."""
401
+ raw = dst.with_name(f".{dst.stem}.raw.wav")
402
+ sf.write(str(raw), samples, rate)
403
+ to_48k_mono(raw, dst, VOICE_FX if fx else "")
404
+ raw.unlink()
405
+
406
+
407
+ def trim_silence(samples: np.ndarray, rate: int, floor_db: float = -45, margin: float = 0.03) -> np.ndarray:
408
+ """Drop leading and trailing near-silence, keeping a short margin."""
409
+ loud = np.flatnonzero(np.abs(samples) > 10 ** (floor_db / 20))
410
+ if not len(loud):
411
+ return samples
412
+ pad = int(margin * rate)
413
+ return samples[max(0, loud[0] - pad):loud[-1] + pad]
414
+
415
+
416
+ def elevenlabs_key() -> str:
417
+ load_env()
418
+ key = os.environ.get("ELEVENLABS_API_KEY", "").strip()
419
+ if not key:
420
+ die("ElevenLabs needs ELEVENLABS_API_KEY (put it in .env at the studio root)")
421
+ return key
422
+
423
+
424
+ def elevenlabs_post(url: str, key: str, body: dict, timeout: int = 120) -> bytes:
425
+ """POST JSON to ElevenLabs and return the audio bytes, retrying rate limits, 5xx and network errors."""
426
+ req = urllib.request.Request(
427
+ url, data=json.dumps(body).encode(), method="POST",
428
+ headers={"xi-api-key": key, "Content-Type": "application/json", "Accept": "audio/mpeg"})
429
+ for attempt in range(4):
430
+ try:
431
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
432
+ return resp.read()
433
+ except urllib.error.HTTPError as e:
434
+ if (e.code == 429 or e.code >= 500) and attempt < 3:
435
+ time.sleep(2 ** attempt * 2)
436
+ continue
437
+ die(f"ElevenLabs {e.code}: {_api_error(e)}")
438
+ except urllib.error.URLError as e:
439
+ if attempt < 3:
440
+ time.sleep(2 ** attempt * 2)
441
+ continue
442
+ die(f"ElevenLabs unreachable: {e.reason}")
443
+
444
+
445
+ def _api_error(e: urllib.error.HTTPError) -> str:
446
+ try:
447
+ detail = json.loads(e.read().decode()).get("detail")
448
+ except Exception:
449
+ return e.reason
450
+ if isinstance(detail, dict):
451
+ return detail.get("message") or detail.get("status") or str(detail)
452
+ return str(detail)
453
+
454
+
455
+ ENGINES = {"kokoro": Kokoro, "chatterbox": Chatterbox, "elevenlabs": ElevenLabs}
456
+
457
+
458
+ def engine_class(script: Script):
459
+ name = str(script.setting("tts")).lower()
460
+ if name not in ENGINES:
461
+ die(f"unknown tts engine '{name}' (choose: {', '.join(ENGINES)})")
462
+ return ENGINES[name]
463
+
464
+
465
+ def to_48k_mono(src: Path, dst: Path, fx: str = "") -> None:
466
+ chain = f"aresample={SAMPLE_RATE}:resampler=soxr" + (f",{fx}" if fx else "")
467
+ run(["ffmpeg", "-v", "error", "-y", "-i", str(src), "-ac", "1", "-ar", str(SAMPLE_RATE),
468
+ "-af", chain, "-c:a", "pcm_s16le", str(dst)])
469
+
470
+
471
+ def read_mono(path: Path) -> np.ndarray:
472
+ data, rate = sf.read(str(path), dtype="float32", always_2d=True)
473
+ if rate != SAMPLE_RATE:
474
+ die(f"{path} is {rate} Hz; expected {SAMPLE_RATE}")
475
+ return data[:, 0]
476
+
477
+
478
+ def find_recording(ep: Path, scene_id: str) -> Path | None:
479
+ for ext in RECORDED_EXT:
480
+ p = ep / "audio" / "recorded" / f"{scene_id}{ext}"
481
+ if p.exists():
482
+ return p
483
+ return None
484
+
485
+
486
+ # ---------------------------------------------------------------- alignment
487
+
488
+ def _norm(w: str) -> str:
489
+ return re.sub(r"[^a-z0-9]", "", w.lower())
490
+
491
+
492
+ def align(script_words: list[str], asr: list[dict], chunk_spans: list[tuple[int, int, float, float]]) -> list[dict]:
493
+ """Give every script word a start/end time.
494
+
495
+ Words that match the transcript take its timings. The rest (respelled names, numbers
496
+ read out, ASR slips) are interpolated between their matched neighbours, bounded by the
497
+ known start/end of the TTS chunk they belong to.
498
+ """
499
+ a = [_norm(w) for w in script_words]
500
+ b = [_norm(w["text"]) for w in asr]
501
+ times: list[tuple[float, float] | None] = [None] * len(a)
502
+ for blk in difflib.SequenceMatcher(None, a, b, autojunk=False).get_matching_blocks():
503
+ for k in range(blk.size):
504
+ w = asr[blk.b + k]
505
+ times[blk.a + k] = (float(w["start"]), float(w["end"]))
506
+
507
+ for lo, hi, c_start, c_end in chunk_spans:
508
+ i = lo
509
+ while i < hi:
510
+ if times[i] is not None:
511
+ i += 1
512
+ continue
513
+ j = i
514
+ while j < hi and times[j] is None:
515
+ j += 1
516
+ left = times[i - 1][1] if i > lo and times[i - 1] else c_start
517
+ right = times[j][0] if j < hi and times[j] else c_end
518
+ right = max(right, left)
519
+ weights = [max(len(script_words[k]), 1) for k in range(i, j)]
520
+ total, acc = sum(weights), left
521
+ for k, wgt in zip(range(i, j), weights):
522
+ step = (right - left) * wgt / total
523
+ times[k] = (acc, acc + step)
524
+ acc += step
525
+ i = j
526
+ return [{"text": w, "start": round(t[0], 3), "end": round(t[1], 3)} for w, t in zip(script_words, times)]
527
+
528
+
529
+ def transcribe(wav: Path) -> list[dict]:
530
+ with tempfile.TemporaryDirectory() as tmp:
531
+ run(hf("transcribe", str(wav), "-d", tmp, "--language", "en", "--json"), env=hf_env())
532
+ return json.loads((Path(tmp) / "transcript.json").read_text())
533
+
534
+
535
+ # ---------------------------------------------------------------- per-scene
536
+
537
+ def voice_scene(ep: Path, script: Script, scene: Scene, tts, table: dict,
538
+ context: list[tuple[str, str]]) -> tuple[dict, object]:
539
+ """Make audio/vo/<id>.wav and its word timings. Returns (words record, tts instance)."""
540
+ vo_dir, tts_dir = ep / "audio" / "vo", ep / "audio" / "tts"
541
+ vo_dir.mkdir(parents=True, exist_ok=True)
542
+ tts_dir.mkdir(parents=True, exist_ok=True)
543
+ out_wav, out_words = vo_dir / f"{scene.id}.wav", vo_dir / f"{scene.id}.words.json"
544
+
545
+ if not scene.chunks: # silent scene
546
+ out_wav.unlink(missing_ok=True)
547
+ rec = {"hash": "silent", "duration": 0.0, "words": [], "cues": {}, "paragraphs": []}
548
+ out_words.write_text(json.dumps(rec, indent=1))
549
+ return rec, tts
550
+
551
+ recording = find_recording(ep, scene.id)
552
+ spans: list[tuple[int, int, float, float]] = []
553
+
554
+ if recording:
555
+ tmp = vo_dir / f".{scene.id}.rec.wav"
556
+ to_48k_mono(recording, tmp)
557
+ audio = read_mono(tmp)
558
+ tmp.unlink()
559
+ n = len(scene.words)
560
+ spans = [(0, n, 0.0, len(audio) / SAMPLE_RATE)]
561
+ source = f"recorded:{recording.name}"
562
+ else:
563
+ pieces, t, w0 = [], 0.0, 0
564
+ engine = engine_class(script)
565
+ for chunk, (prev_text, next_text) in zip(scene.chunks, context):
566
+ say = respell(" ".join(chunk.words), table)
567
+ # Context is left out of the key: editing a paragraph doesn't re-synthesise its neighbours.
568
+ key = sha(say, *engine.cache_tag(script))[:20]
569
+ cached = tts_dir / f"{key}.wav"
570
+ if not cached.exists():
571
+ if tts is None:
572
+ info(f" loading {script.setting('tts')}...")
573
+ tts = engine(script)
574
+ info(f" tts {scene.id}: {say[:70]}{'...' if len(say) > 70 else ''}")
575
+ started = time.monotonic()
576
+ tts.synth_to(say, cached, prev_text, next_text)
577
+ tts.seconds += time.monotonic() - started
578
+ seg = read_mono(cached)
579
+ pieces.append(seg)
580
+ spans.append((w0, w0 + len(chunk.words), t, t + len(seg) / SAMPLE_RATE))
581
+ t += len(seg) / SAMPLE_RATE
582
+ w0 += len(chunk.words)
583
+ if chunk.gap_after > 0:
584
+ pieces.append(np.zeros(int(round(chunk.gap_after * SAMPLE_RATE)), dtype=np.float32))
585
+ t += chunk.gap_after
586
+ audio = np.concatenate(pieces)
587
+ source = "tts"
588
+
589
+ digest = sha(audio.tobytes(), scene.words, list(scene.cues.items()))[:20]
590
+ if out_words.exists() and out_wav.exists():
591
+ prev = json.loads(out_words.read_text())
592
+ if prev.get("hash") == digest:
593
+ return prev, tts
594
+
595
+ sf.write(str(out_wav), audio, SAMPLE_RATE, subtype="PCM_16")
596
+ info(f" align {scene.id} ({source}, {len(audio) / SAMPLE_RATE:.1f}s)")
597
+ words = align(scene.words, transcribe(out_wav), spans)
598
+
599
+ cues = {}
600
+ for name, idx in scene.cues.items():
601
+ cues[name] = words[idx]["start"] if idx < len(words) else words[-1]["end"]
602
+ rec = {
603
+ "hash": digest,
604
+ "source": source,
605
+ "duration": round(len(audio) / SAMPLE_RATE, 3),
606
+ "words": words,
607
+ "cues": cues,
608
+ "paragraphs": [round(s[2], 3) for s in spans],
609
+ }
610
+ out_words.write_text(json.dumps(rec, indent=1))
611
+ return rec, tts
612
+
613
+
614
+ def voice_all(ep: Path, script: Script, only: set[str] | None = None) -> dict[str, dict]:
615
+ table = load_pronunciations(script)
616
+ # Each chunk's neighbours across the whole script, for engines that stitch prosody.
617
+ texts = [respell(" ".join(c.words), table) for sc in script.scenes for c in sc.chunks]
618
+ context = [(texts[i - 1] if i else "", texts[i + 1] if i + 1 < len(texts) else "") for i in range(len(texts))]
619
+ tts = None
620
+ records = {}
621
+ pos = 0
622
+ for scene in script.scenes:
623
+ ctx, pos = context[pos:pos + len(scene.chunks)], pos + len(scene.chunks)
624
+ if only and scene.id not in only:
625
+ wf = ep / "audio" / "vo" / f"{scene.id}.words.json"
626
+ if wf.exists():
627
+ records[scene.id] = json.loads(wf.read_text())
628
+ continue
629
+ records[scene.id], tts = voice_scene(ep, script, scene, tts, table, ctx)
630
+ if tts is not None and tts.chars:
631
+ mins, secs = divmod(round(tts.seconds), 60)
632
+ info(f" {script.setting('tts')}: {tts.chars} characters synthesised in {mins}m {secs:02d}s")
633
+ return records