# core/tts.py """Story text → grandmother-voice audio (EN/BN). EN → VoxCPM2 Voice Design persona (Build Order step 5 — still stubbed). BN → get_config().tts_bn_backend ('indic_parler' or 'indic_tts'), synthesised on Modal via Indic Parler-TTS. The grandmother persona is controlled by the caption below — tune it for warmth and storytelling pace (CLAUDE.md §9). Returns (None, label) if TTS unavailable or failed. Never raises. """ import tempfile from pathlib import Path from core.model_config import UI_MOCK, get_config from core.modal_infra import synthesize_bengali_remote, synthesize_english_remote # A pre-generated sample clip for RUPKOTHA_MOCK=1, so the audio player has something # to show during local UI dev (no Modal/GPU). None if the sample isn't present. _MOCK_WAV = next( (str(p) for p in [ Path("finetune/data/sample/demo_story_ai4bharat.wav"), Path("finetune/data/sample/demo_story_output_sampled.wav"), ] if p.exists()), None, ) # Grandmother voice caption — controls the Indic Parler-TTS speaker persona. # Tune for warmth and pace; run 5–10 samples and keep the best (CLAUDE.md §9). GRANDMOTHER_CAPTION_BN = ( "Aditi speaks in the warm, tender voice of a loving elderly Bengali " "grandmother telling a bedtime story to her beloved grandchild. Her pace is " "very slow, gentle, and unhurried, pausing softly at every sentence. Her " "delivery is affectionate and expressive, rising and falling with the natural " "soothing melody of Bengali storytelling, full of warmth and calm. The " "recording is very clear, close, and intimate, with no background noise." ) # Grandmother Voice Design persona for VoxCPM2 (English). This is prefixed to the # story text in parentheses by core/modal_infra.py — keep it short and concrete. # Tune for warmth and a slow, storytelling pace (CLAUDE.md §11, build step 5). GRANDMOTHER_VOICE_EN = ( "a warm, loving elderly grandmother reading a bedtime story, " "slow and gentle pace, soft and soothing tone" ) def _write_wav(audio_bytes: bytes) -> str: """Persist WAV bytes to a temp file and return its path.""" tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) try: tmp.write(audio_bytes) finally: tmp.close() return tmp.name def speak( text: str, language: str, # 'en' or 'bn' voice: str = "grandmother", ) -> tuple[str | None, str]: """Returns (wav_path_or_None, tts_model_label). Never raises.""" cfg = get_config() if UI_MOCK: # local UI dev — sample clip, no Modal/GPU return _MOCK_WAV, "UI-mock" if language == "bn": label = cfg.tts_bn_backend if not (text or "").strip(): return None, label try: audio_bytes = synthesize_bengali_remote(text, GRANDMOTHER_CAPTION_BN) if not audio_bytes: return None, label return _write_wav(audio_bytes), label except Exception as e: # noqa: BLE001 — never raise to the UI print(f"[tts.py] Bengali TTS failed: {e}") return None, label # English path: VoxCPM2 Voice Design. label = cfg.tts_en_backend if not (text or "").strip(): return None, label try: audio_bytes = synthesize_english_remote(text, GRANDMOTHER_VOICE_EN) if not audio_bytes: return None, label return _write_wav(audio_bytes), label except Exception as e: # noqa: BLE001 — never raise to the UI print(f"[tts.py] English TTS failed: {e}") return None, label