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.
- explainer_studio-0.1.5.dist-info/METADATA +149 -0
- explainer_studio-0.1.5.dist-info/RECORD +37 -0
- explainer_studio-0.1.5.dist-info/WHEEL +4 -0
- explainer_studio-0.1.5.dist-info/entry_points.txt +2 -0
- explainer_studio-0.1.5.dist-info/licenses/LICENSE +41 -0
- studio/__init__.py +0 -0
- studio/build.py +196 -0
- studio/chatterbox_worker.py +96 -0
- studio/cli.py +265 -0
- studio/common.py +160 -0
- studio/data/brand/brand.css +145 -0
- studio/data/brand/fonts/JetBrainsMono.ttf +0 -0
- studio/data/brand/fonts/OFL-JetBrainsMono.txt +93 -0
- studio/data/brand/fonts/OFL-PTSerif.txt +93 -0
- studio/data/brand/fonts/OFL-SourceSans3.txt +93 -0
- studio/data/brand/fonts/PTSerif-Bold.ttf +0 -0
- studio/data/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
- studio/data/brand/fonts/PTSerif-Italic.ttf +0 -0
- studio/data/brand/fonts/PTSerif-Regular.ttf +0 -0
- studio/data/brand/fonts/SourceSans3-Bold.ttf +0 -0
- studio/data/brand/fonts/SourceSans3-Italic.ttf +0 -0
- studio/data/brand/fonts/SourceSans3.ttf +0 -0
- studio/data/brand/frame.md +108 -0
- studio/data/brand/pronunciations.yaml +8 -0
- studio/data/brand/vendor/gsap.min.js +11 -0
- studio/data/templates/scenes/blank.html +46 -0
- studio/data/templates/scenes/outro.html +50 -0
- studio/data/templates/scenes/points.html +55 -0
- studio/data/templates/scenes/quote.html +41 -0
- studio/data/templates/scenes/stat.html +57 -0
- studio/data/templates/scenes/title.html +46 -0
- studio/data/templates/script.md +51 -0
- studio/data/templates/thumbnail.html +28 -0
- studio/music.py +66 -0
- studio/render.py +271 -0
- studio/script.py +158 -0
- 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
|