File size: 11,494 Bytes
7c6ffa6
 
 
 
 
 
 
 
 
 
3bcdb36
7c6ffa6
 
 
 
3bcdb36
7c6ffa6
 
 
3bcdb36
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
3bcdb36
 
7c6ffa6
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
3bcdb36
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
3bcdb36
 
 
 
 
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
 
3bcdb36
7c6ffa6
 
 
 
 
3bcdb36
 
7c6ffa6
 
 
 
 
 
3bcdb36
 
 
 
 
7c6ffa6
3bcdb36
 
 
7c6ffa6
 
 
 
 
 
 
3bcdb36
7c6ffa6
 
3bcdb36
 
 
 
 
 
 
 
 
 
7c6ffa6
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
"""Generate locally hosted, teacher-paced Kokoro narration for SSLC chapters.

This intentionally writes a parallel ``kokoro-full-chapter`` manifest. Existing
Deepgram audio remains untouched so every generation run is reversible.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import re
import subprocess
import sys
import warnings
from collections import OrderedDict
from pathlib import Path

# Silence torch/kokoro warnings so nothing reaches stderr. Windows PowerShell 5.1
# with $ErrorActionPreference='Stop' promotes ANY native-command stderr line to a
# terminating NativeCommandError, which made the render-*-teacher-v3-all.ps1
# runners abort at Kokoro model load (~30s in) and record the torch UserWarning as
# a bogus "failure". The warnings are harmless; suppressing them keeps stderr clean.
warnings.filterwarnings("ignore")
os.environ.setdefault("PYTHONWARNINGS", "ignore")
logging.disable(logging.WARNING)

import soundfile as sf


ROOT = Path(__file__).resolve().parents[2]
DEFAULT_IDS = [f"phy-p1-c{index}" for index in range(1, 8)]
MIN_WORDS = 18
MAX_WORDS = 82


def words(text: str) -> int:
    return len(re.findall(r"\S+", text))


def spoken_text(text: str) -> str:
    """Turn compact board notation into narration that sounds like a teacher."""
    substitutions = (
        (r"\bv\s*=\s*f\s*(?:lambda|λ)\b", "v equals f multiplied by lambda"),
        (r"\bflambda\b", "f multiplied by lambda"),
        (r"\bλ\b", "lambda"),
        (r"\bμ\b", "mu"),
        (r"\bΔ\b", "delta"),
        (r"\b([0-9]+)\s*Hz\b", r"\1 hertz"),
        (r"\b([0-9]+)\s*kHz\b", r"\1 kilohertz"),
        (r"\bm/s\b", "metres per second"),
        (r"\bcm\b", "centimetres"),
        (r"\bmm\b", "millimetres"),
    )
    result = text
    for pattern, replacement in substitutions:
        result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
    result = re.sub(r"\s+", " ", result).strip()
    return result


def sentence_parts(text: str) -> list[str]:
    parts = re.split(r"(?<=[.!?])\s+", text.strip())
    return [part.strip() for part in parts if part.strip()]


def split_long_text(text: str, max_words: int = MAX_WORDS) -> list[str]:
    sentences = sentence_parts(text)
    output: list[str] = []
    current: list[str] = []
    current_words = 0
    for sentence in sentences:
        sentence_words = words(sentence)
        if current and current_words + sentence_words > max_words:
            output.append(" ".join(current))
            current = []
            current_words = 0
        if sentence_words > max_words:
            tokens = sentence.split()
            for start in range(0, len(tokens), max_words):
                if current:
                    output.append(" ".join(current))
                    current = []
                    current_words = 0
                output.append(" ".join(tokens[start : start + max_words]))
        else:
            current.append(sentence)
            current_words += sentence_words
    if current:
        output.append(" ".join(current))
    return output


def merge_scene_chunks(chunks: list[dict]) -> list[dict]:
    """Merge tiny TTS calls while preserving scene boundaries and caption text."""
    scenes: OrderedDict[str, list[dict]] = OrderedDict()
    for chunk in chunks:
        scenes.setdefault(chunk["sceneId"], []).append(chunk)

    merged: list[dict] = []
    for scene_id, scene_chunks in scenes.items():
        scene_text = " ".join(chunk["text"].strip() for chunk in scene_chunks if chunk.get("text", "").strip())
        pieces = split_long_text(scene_text)

        # Avoid a clipped-sounding final phrase by folding it into the prior piece.
        if len(pieces) > 1 and words(pieces[-1]) < MIN_WORDS and words(pieces[-2]) + words(pieces[-1]) <= MAX_WORDS + 12:
            pieces[-2] = f"{pieces[-2]} {pieces[-1]}"
            pieces.pop()

        for index, text in enumerate(pieces, start=1):
            source = scene_chunks[min(index - 1, len(scene_chunks) - 1)]
            merged.append(
                {
                    "id": f"{scene_id}-voice-{index}",
                    "sceneId": scene_id,
                    "segmentKind": source.get("segmentKind", "concept"),
                    "text": text,
                    "spokenText": spoken_text(text),
                    "characterCount": len(text),
                    "pauseAfterSeconds": 0.45 if text.rstrip().endswith("?") else 0.22,
                }
            )
    return merged


def preserve_authored_chunks(chunks: list[dict]) -> list[dict]:
    """Keep Biology V4 cue IDs intact so measured audio drives exact visual beats."""
    prepared: list[dict] = []
    for chunk in chunks:
        text = chunk.get("text", "").strip()
        if not text:
            continue
        prepared.append(
            {
                **chunk,
                "spokenText": chunk.get("spokenText") or spoken_text(text),
                "characterCount": chunk.get("characterCount") or len(text),
                "pauseAfterSeconds": float(chunk.get("pauseAfterSeconds") or 0.22),
            }
        )
    return prepared


def probe_duration(path: Path) -> float:
    completed = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)],
        capture_output=True,
        check=True,
        text=True,
    )
    return round(float(completed.stdout.strip()), 6)


def synthesize_chapter(
    pipeline,
    chapter_id: str,
    voice: str,
    speed: float,
    force: bool,
    source_audio_name: str = "full-chapter",
    output_audio_name: str = "kokoro-full-chapter",
    preserve_source_chunks: bool = False,
) -> dict:
    source_dir = ROOT / "outputs" / "video" / "tuition" / chapter_id / "audio" / source_audio_name
    source_manifest_path = source_dir / "manifest.json"
    if not source_manifest_path.exists():
        raise FileNotFoundError(f"Missing source narration manifest: {source_manifest_path}")

    source_manifest = json.loads(source_manifest_path.read_text(encoding="utf-8"))
    chunks = preserve_authored_chunks(source_manifest["chunks"]) if preserve_source_chunks else merge_scene_chunks(source_manifest["chunks"])
    output_dir = source_dir.parent / output_audio_name
    chunk_dir = output_dir / "chunks"
    chunk_dir.mkdir(parents=True, exist_ok=True)
    manifest_path = output_dir / "manifest.json"
    existing: dict[str, dict] = {}
    if manifest_path.exists() and not force:
        existing_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        settings_match = (
            existing_manifest.get("model") == "hexgrad/Kokoro-82M"
            and existing_manifest.get("voice") == voice
            and float(existing_manifest.get("speed", -1)) == speed
        )
        if settings_match:
            existing = {item["id"]: item for item in existing_manifest["chunks"]}
        else:
            print(
                f"[{chapter_id}] voice settings changed; regenerating all phrases "
                f"with {voice} at {speed}x",
                flush=True,
            )

    generated: list[dict] = []
    for index, chunk in enumerate(chunks, start=1):
        output_path = chunk_dir / f"{chunk['id']}.wav"
        cached = existing.get(chunk["id"])
        stale = cached is not None and cached.get("spokenText") != chunk["spokenText"]
        if force or not output_path.exists() or stale:
            audio_parts = []
            for _graphemes, _phonemes, audio in pipeline(chunk["spokenText"], voice=voice, speed=speed, split_pattern=r"\n+"):
                audio_parts.append(audio)
            if not audio_parts:
                raise RuntimeError(f"Kokoro produced no audio for {chunk['id']}")
            import numpy as np

            joined = np.concatenate(audio_parts)
            sf.write(output_path, joined, 24_000, subtype="PCM_16")

        if cached and not stale and output_path.exists() and not force:
            # Timing metadata belongs to the current authored cue contract even
            # when the waveform itself is safely reused from cache.
            generated.append({**cached, "pauseAfterSeconds": chunk["pauseAfterSeconds"]})
        else:
            relative_path = output_path.relative_to(ROOT).as_posix()
            generated.append(
                {
                    **chunk,
                    "outputPath": relative_path,
                    "status": "synthesized",
                    "durationSeconds": probe_duration(output_path),
                }
            )
        if index == 1 or index % 25 == 0 or index == len(chunks):
            print(f"[{chapter_id}] {index}/{len(chunks)} teacher phrases", flush=True)

    manifest = {
        "provider": "kokoro-local",
        "model": "hexgrad/Kokoro-82M",
        "voice": voice,
        "speed": speed,
        "sampleRate": 24_000,
        "configured": True,
        "dryRun": False,
        "sourceManifest": source_manifest_path.relative_to(ROOT).as_posix(),
        "sourceChunkCount": len(source_manifest["chunks"]),
        "naturalPhraseCount": len(generated),
        "preserveSourceChunks": preserve_source_chunks,
        "totalCharacters": sum(chunk["characterCount"] for chunk in generated),
        "totalDurationSeconds": round(sum(chunk["durationSeconds"] for chunk in generated), 3),
        "chunks": generated,
    }
    output_dir.mkdir(parents=True, exist_ok=True)
    manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print(f"[{chapter_id}] wrote {manifest_path}", flush=True)
    return manifest


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--chapter-id", action="append", dest="chapter_ids", help="Repeat to select chapters; defaults to all seven Physics chapters.")
    # Heart is the clearest natural Kokoro voice in the current student proof.
    # Scene selection uses measured audio duration, so the calmer pace does not
    # push the final production package outside its 30-40 minute target.
    parser.add_argument("--voice", default="af_heart")
    parser.add_argument("--speed", type=float, default=1.0)
    parser.add_argument("--force", action="store_true")
    parser.add_argument("--source-audio-name", default="full-chapter")
    parser.add_argument("--output-audio-name", default="kokoro-full-chapter")
    parser.add_argument("--preserve-source-chunks", action="store_true")
    args = parser.parse_args()

    os.environ.setdefault("USE_TF", "0")
    os.environ.setdefault("TRANSFORMERS_NO_TF", "1")
    os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
    from kokoro import KPipeline

    pipeline = KPipeline(lang_code="a", repo_id="hexgrad/Kokoro-82M")
    summaries = []
    for chapter_id in args.chapter_ids or DEFAULT_IDS:
        manifest = synthesize_chapter(
            pipeline,
            chapter_id,
            args.voice,
            args.speed,
            args.force,
            args.source_audio_name,
            args.output_audio_name,
            args.preserve_source_chunks,
        )
        summaries.append(
            {
                "chapterId": chapter_id,
                "phrases": manifest["naturalPhraseCount"],
                "durationMinutes": round(manifest["totalDurationSeconds"] / 60, 2),
            }
        )
    print(json.dumps(summaries, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())