| """Kokoro-backed TTS rendering.""" |
|
|
| from __future__ import annotations |
|
|
| import wave |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| from .schema import Script |
|
|
| SAMPLE_RATE = 24_000 |
| KOKORO_REPO_ID = "hexgrad/Kokoro-82M" |
|
|
|
|
| def render_script_wav(script: Script, output_path: Path) -> Path: |
| """Render dialogue lines with Kokoro into one mono WAV. |
| |
| This is intentionally simple: no delivery FX, radio chain, ducking, or SFX yet. |
| It proves that the locked voice roster can produce multi-voice audio. |
| """ |
| from kokoro import KPipeline |
|
|
| script.validate() |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| cast_voice = {member.id: member.voice.value for member in script.cast} |
| pipelines: dict[str, KPipeline] = {} |
| with wave.open(str(output_path), "wb") as wav_file: |
| wav_file.setnchannels(1) |
| wav_file.setsampwidth(2) |
| wav_file.setframerate(SAMPLE_RATE) |
| for scene in script.scenes: |
| _write_silence(wav_file, 0.25) |
| for line in scene.lines: |
| voice = cast_voice[line.cast] |
| lang = voice[0] |
| pipeline = pipelines.get(lang) |
| if pipeline is None: |
| pipeline = KPipeline(lang_code=lang, repo_id=KOKORO_REPO_ID) |
| pipelines[lang] = pipeline |
| for result in pipeline(line.text, voice=voice, speed=1.0, split_pattern=r"\n+"): |
| if result.audio is None: |
| continue |
| audio = np.clip(result.audio.numpy(), -1.0, 1.0) |
| wav_file.writeframes((audio * 32767).astype(np.int16).tobytes()) |
| _write_silence(wav_file, 0.22) |
| return output_path |
|
|
|
|
| def _write_silence(wav_file: wave.Wave_write, seconds: float) -> None: |
| frames = int(SAMPLE_RATE * seconds) |
| wav_file.writeframes(b"\x00\x00" * frames) |
|
|