"""Generate real bilingual narration for the Social Science exam-point proof. English uses local Kokoro. Malayalam uses the locally cached AI4Bharat Indic Parler model. The lesson JSON is already separately authored in each language; this script never machine-translates one version into the other. """ from __future__ import annotations import argparse import hashlib import json import math import os import re import subprocess import sys from pathlib import Path import numpy as np import soundfile as sf ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) def probe_duration(path: Path) -> float: run = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path), ], check=True, capture_output=True, text=True, ) return round(float(run.stdout.strip()), 6) def append_silence(path: Path, seconds: float) -> None: if seconds <= 0: return audio, sample_rate = sf.read(path, dtype="float32", always_2d=True) silence = np.zeros((round(seconds * sample_rate), audio.shape[1]), dtype=np.float32) sf.write(path, np.concatenate([audio, silence], axis=0), sample_rate, subtype="PCM_16") def retime_audio(path: Path, speed: float) -> None: if abs(speed - 1.0) < 0.001: return if speed < 0.5 or speed > 2.0: raise ValueError("Audio speed must be between 0.5 and 2.0.") temporary = path.with_name(f"{path.stem}.retimed.wav") subprocess.run( [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(path), "-filter:a", f"atempo={speed:.4f}", "-c:a", "pcm_s16le", str(temporary), ], check=True, ) temporary.replace(path) def kokoro_audio(text: str, output: Path, voice: str, speed: float) -> None: from kokoro import KPipeline pipeline = KPipeline(lang_code="b" if voice.startswith("b") else "a") parts = [ audio for _graphemes, _phonemes, audio in pipeline( re.sub(r"\s+", " ", text).strip(), voice=voice, speed=speed, split_pattern=r"\n+", ) ] if not parts: raise RuntimeError("Kokoro returned no audio.") sf.write(output, np.concatenate(parts).astype(np.float32), 24_000, subtype="PCM_16") def ai4bharat_audio(text: str, output: Path, voice: str) -> None: from app.schemas.video import VideoSceneAudioInput from app.services.tts_provider import AI4BharatIndicParlerTTSProvider provider = AI4BharatIndicParlerTTSProvider() sentences = [ part.strip() for part in re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if part.strip() ] narration_chunks: list[str] = [] for sentence in sentences: words = sentence.split() chunk_count = max(1, math.ceil(len(words) / 7)) base_size, larger_chunks = divmod(len(words), chunk_count) cursor = 0 for chunk_index in range(chunk_count): chunk_size = base_size + (1 if chunk_index < larger_chunks else 0) narration_chunks.append(" ".join(words[cursor : cursor + chunk_size])) cursor += chunk_size rendered_parts: list[tuple[np.ndarray, int]] = [] temporary_paths: list[Path] = [] try: for chunk_index, narration_chunk in enumerate(narration_chunks, start=1): chunk_hash = hashlib.sha256(narration_chunk.encode("utf-8")).hexdigest()[:8] chunk_output = output.with_name( f"{output.stem}.part-{chunk_index:02d}-{chunk_hash}.wav" ) temporary_paths.append(chunk_output) print( json.dumps( { "event": "social_science_proof_tts_chunk_start", "chunk": chunk_index, "chunkCount": len(narration_chunks), "wordCount": len(narration_chunk.split()), "text": narration_chunk, }, ensure_ascii=True, ), flush=True, ) if chunk_output.exists(): actual_path = chunk_output else: scene = VideoSceneAudioInput( scene_id=chunk_index, type="concept", duration_seconds=max(3.0, len(narration_chunk.split()) / 1.75), voice_text=narration_chunk, ) result = provider.generate_scene_audio( scene=scene, output_file=chunk_output, voice_mode="malayalam_soft", voice=voice, language="ml", ) actual_path = result.file_path audio, sample_rate = sf.read(actual_path, dtype="float32", always_2d=True) rendered_parts.append((audio, sample_rate)) if actual_path != chunk_output: temporary_paths.append(actual_path) if not rendered_parts: raise RuntimeError("AI4Bharat returned no audio.") sample_rate = rendered_parts[0][1] if any(part_sample_rate != sample_rate for _audio, part_sample_rate in rendered_parts): raise RuntimeError("AI4Bharat chunks used inconsistent sample rates.") join_pause = np.zeros((round(0.16 * sample_rate), rendered_parts[0][0].shape[1]), dtype=np.float32) combined: list[np.ndarray] = [] for part_index, (audio, _sample_rate) in enumerate(rendered_parts): if part_index: combined.append(join_pause) combined.append(audio) sf.write(output, np.concatenate(combined, axis=0), sample_rate, subtype="PCM_16") finally: if output.exists(): for temporary_path in temporary_paths: temporary_path.unlink(missing_ok=True) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--lesson", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--provider", choices=("kokoro", "ai4bharat"), required=True) parser.add_argument("--language", choices=("en", "ml")) parser.add_argument("--voice") parser.add_argument("--speed", type=float, default=0.96) parser.add_argument("--audio-speed", type=float, default=1.0) parser.add_argument( "--retime-existing-factor", type=float, default=1.0, help="Post-process existing WAV chunks by this tempo factor without re-synthesising them.", ) parser.add_argument("--force", 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") lesson_path = args.lesson if args.lesson.is_absolute() else ROOT / args.lesson output_dir = args.output_dir if args.output_dir.is_absolute() else ROOT / args.output_dir output_dir.mkdir(parents=True, exist_ok=True) lesson = json.loads(lesson_path.read_text(encoding="utf-8")) language = args.language or lesson.get("language") if language not in {"en", "ml"}: raise ValueError("Lesson language must be supplied with --language en|ml.") if language == "en" and args.provider != "kokoro": raise ValueError("English proof narration must use Kokoro.") if language == "ml" and args.provider != "ai4bharat": raise ValueError("Malayalam proof narration must use AI4Bharat.") voice = args.voice or ("af_heart" if args.provider == "kokoro" else "Anjali") items = lesson.get("units") or lesson.get("scenes") if not items: raise ValueError("Lesson contains no narration units.") chunks: list[dict] = [] for index, scene in enumerate(items, start=1): narration = scene.get(f"tts_text_{language}") or scene.get("narration") if not narration: raise ValueError(f"Narration unit {scene['id']} is missing tts_text_{language}.") if language == "ml": latin_words = re.findall(r"\b[A-Za-z]{3,}\b", narration) if len(latin_words) > 1: raise ValueError( f"Malayalam narration unit {scene['id']} contains excessive Latin-script prose: {latin_words}" ) output = output_dir / f"{index:02d}-{scene['id']}.wav" retrieval_pause = float(scene.get("retrievalPauseSeconds", 0)) tail_pause = float(scene.get("pauseAfterSeconds", 0.35)) if args.force or not output.exists(): print( json.dumps( { "event": "social_science_proof_tts_start", "provider": args.provider, "scene": scene["id"], "index": index, } ), flush=True, ) if args.provider == "kokoro": kokoro_audio(narration, output, voice, args.speed) else: ai4bharat_audio(narration, output, voice) retime_audio(output, args.audio_speed) append_silence(output, retrieval_pause + tail_pause) elif abs(args.retime_existing_factor - 1.0) > 0.001: retime_audio(output, args.retime_existing_factor) duration_seconds = probe_duration(output) chunks.append( { "id": scene["id"], "sceneIndex": index - 1, "outputPath": output.relative_to(ROOT).as_posix(), "narration": narration, "displayText": scene.get(f"display_text_{language}", narration), "wordCount": len(re.findall(r"\S+", narration)), "durationSeconds": duration_seconds, "retrievalPauseSeconds": retrieval_pause, "pauseAfterSeconds": tail_pause, } ) print( json.dumps( { "event": "social_science_proof_tts_complete", "provider": args.provider, "scene": scene["id"], "durationSeconds": duration_seconds, } ), flush=True, ) manifest = { "provider": f"{args.provider}-local", "voice": voice, "synthesisSpeed": args.speed if args.provider == "kokoro" else 1.0, "postAudioSpeed": args.audio_speed * args.retime_existing_factor, "language": language, "lessonPath": lesson_path.relative_to(ROOT).as_posix(), "sceneCount": len(chunks), "wordCount": sum(item["wordCount"] for item in chunks), "totalDurationSeconds": round(sum(item["durationSeconds"] for item in chunks), 3), "chunks": chunks, } manifest_path = output_dir / "manifest.json" manifest_path.write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) print( json.dumps( { "event": "social_science_proof_manifest_ready", "manifest": str(manifest_path), "durationSeconds": manifest["totalDurationSeconds"], "sceneCount": manifest["sceneCount"], } ) ) return 0 if __name__ == "__main__": raise SystemExit(main())