File size: 11,961 Bytes
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
"""Generate locally hosted, teacher-paced AI4Bharat (Indic Parler-TTS) narration for SSLC chapters.

Kokoro-82M was rejected as too robotic for a student-facing teaching voice. AI4Bharat's
Indic Parler-TTS is far clearer (0% WER on a physics terminology test) but roughly
2.5x slower than real time to generate on this GPU, so this script commits each
phrase to disk as it goes and can resume a partially finished chapter.

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

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
import time
from collections import OrderedDict
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))

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.

    Board/notebook shorthand ("Time Period (T).", "f = 1/T", "Resonance =
    Matching Frequencies") reads fine on screen but an AI4Bharat/Kokoro voice
    given that text verbatim tries to pronounce "=", "/", and bare symbol
    letters and produces garbled audio (confirmed via Whisper transcripts:
    "T = 1/f" came out as "T F E E S C A F F F F"). Every equation/symbol
    aside must become plain words before it reaches the TTS engine.
    """
    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)

    # Board-shorthand parenthetical asides ("(T)", "(m)", "(unit: s)", "(C & R)")
    # are symbol reminders for the screen, not something a teacher would say
    # out loud — the sentence already states the full word. Drop short ones
    # entirely rather than let the TTS try to sound out the symbol.
    result = re.sub(r"\s*\([^()]{1,24}\)", "", result)

    # Remaining "=" only appears in word/symbol equations at this point
    # (formula-specific patterns above already consumed the ones they
    # handle) — speak it plainly.
    result = re.sub(r"\s*=\s*", " equals ", result)

    # Remaining "/" is equation shorthand ("1/T", "f/2") — the unit-specific
    # patterns above (m/s) already consumed the non-equation cases.
    result = re.sub(r"\b([A-Za-z0-9]+)\s*/\s*([A-Za-z0-9]+)\b", r"\1 over \2", result)

    result = re.sub(r"\s+", " ", result).strip()
    result = re.sub(r"\s+([.,!?])", r"\1", result)
    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)

        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 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(provider, chapter_id: str, voice: str, language: str, force: bool) -> dict:
    from app.schemas.video import VideoSceneAudioInput

    source_dir = ROOT / "outputs" / "video" / "tuition" / chapter_id / "audio" / "full-chapter"
    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 = merge_scene_chunks(source_manifest["chunks"])
    output_dir = source_dir.parent / "ai4bharat-full-chapter"
    chunk_dir = output_dir / "chunks"
    chunk_dir.mkdir(parents=True, exist_ok=True)
    manifest_path = output_dir / "manifest.json"

    generated: list[dict] = []
    existing = {}
    if manifest_path.exists() and not force:
        existing = {item["id"]: item for item in json.loads(manifest_path.read_text(encoding="utf-8"))["chunks"]}

    start_time = time.time()
    for index, chunk in enumerate(chunks, start=1):
        output_path = chunk_dir / f"{chunk['id']}.wav"
        cached = existing.get(chunk["id"])
        # If spoken_text() now produces different text for this phrase than
        # what was actually synthesized (e.g. after fixing the formula/unit
        # normalization bug), the cached audio is stale and must be redone —
        # don't just trust that the id was seen before.
        stale = cached is not None and cached.get("spokenText") != chunk["spokenText"]
        if not force and cached and not stale and output_path.exists():
            generated.append(cached)
        else:
            scene = VideoSceneAudioInput(
                scene_id=index,
                type="concept",
                duration_seconds=max(2.0, words(chunk["spokenText"]) / 2.2),
                voice_text=chunk["spokenText"],
            )
            result = provider.generate_scene_audio(
                scene=scene,
                output_file=output_path,
                voice_mode="default",
                voice=voice,
                language=language,
            )
            if result.file_path != output_path:
                result.file_path.rename(output_path)
            generated.append(
                {
                    **chunk,
                    "outputPath": str((source_dir.parent / "ai4bharat-full-chapter" / "chunks" / output_path.name).relative_to(ROOT)).replace("\\", "/"),
                    "status": "synthesized",
                    "durationSeconds": probe_duration(output_path),
                }
            )
            # Persist progress after every phrase so a long unattended run is resumable.
            manifest = {
                "provider": "ai4bharat-local",
                "model": "ai4bharat/indic-parler-tts",
                "voice": voice,
                "language": language,
                "configured": True,
                "dryRun": False,
                "sourceManifest": str(source_manifest_path.relative_to(ROOT)).replace("\\", "/"),
                "sourceChunkCount": len(source_manifest["chunks"]),
                "naturalPhraseCount": len(chunks),
                "completedPhraseCount": len(generated),
                "totalCharacters": sum(c["characterCount"] for c in generated),
                "totalDurationSeconds": round(sum(c["durationSeconds"] for c in generated), 3),
                "chunks": generated,
            }
            manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

        if index == 1 or index % 10 == 0 or index == len(chunks):
            elapsed = time.time() - start_time
            rate = elapsed / index if index else 0
            remaining = rate * (len(chunks) - index)
            print(
                f"[{chapter_id}] {index}/{len(chunks)} teacher phrases "
                f"(elapsed {elapsed/60:.1f}m, est remaining {remaining/60:.1f}m)",
                flush=True,
            )

    manifest = {
        "provider": "ai4bharat-local",
        "model": "ai4bharat/indic-parler-tts",
        "voice": voice,
        "language": language,
        "configured": True,
        "dryRun": False,
        "sourceManifest": str(source_manifest_path.relative_to(ROOT)).replace("\\", "/"),
        "sourceChunkCount": len(source_manifest["chunks"]),
        "naturalPhraseCount": len(generated),
        "totalCharacters": sum(chunk["characterCount"] for chunk in generated),
        "totalDurationSeconds": round(sum(chunk["durationSeconds"] for chunk in generated), 3),
        "chunks": generated,
    }
    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.")
    parser.add_argument("--voice", default="Mary")
    parser.add_argument("--language", default="en")
    parser.add_argument("--force", action="store_true")
    args = parser.parse_args()

    import os

    os.environ.setdefault("USE_TF", "0")
    os.environ.setdefault("TRANSFORMERS_NO_TF", "1")
    os.environ.setdefault("HF_HUB_DISABLE_XET", "1")

    from app.services.tts_provider import AI4BharatIndicParlerTTSProvider

    provider = AI4BharatIndicParlerTTSProvider()
    summaries = []
    for chapter_id in args.chapter_ids or DEFAULT_IDS:
        manifest = synthesize_chapter(provider, chapter_id, args.voice, args.language, args.force)
        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())