DoodleBook / indic_tts.py
Codex
Fix Kannada narration: 3-tier TTS + error surfacing + longer English audio
e0d2309
Raw
History Blame Contribute Delete
6.31 kB
"""Kannada TTS — tier 1: sush0401/IndicF5-Kannada-Bedtime-v2 (fine-tuned).
Tier 2: facebook/mms-tts-kan (VITS, 16kHz, no voice cloning).
Tier 3: gTTS (Google, always works, generic Kannada voice).
ZeroGPU pattern: GPU models loaded to CPU at module scope so ZeroGPU packs their
tensors. Inside inference functions (called from @spaces.GPU), .to("cuda") is
called and ZeroGPU transfers packed tensors to GPU — no re-download needed.
"""
from __future__ import annotations
import logging
import os
import re
import tempfile
import numpy as np
import torch
logger = logging.getLogger(__name__)
_FINETUNE_HUB = "sush0401/IndicF5-Kannada-Bedtime-v2"
_FALLBACK_HUB = "facebook/mms-tts-kan"
_indic_model = None # IndicF5 fine-tuned (with voice cloning)
_mms_model = None # MMS-TTS fallback (no voice cloning)
_mms_tok = None
_use_indic = False # set True after successful IndicF5 load
MMS_SR = 16_000
def _load_indic():
global _indic_model, _use_indic
from transformers import AutoModel
logger.info(f"Loading IndicF5 from {_FINETUNE_HUB}")
# Load to CPU — ZeroGPU packs these tensors.
_indic_model = AutoModel.from_pretrained(
_FINETUNE_HUB, trust_remote_code=True,
)
_use_indic = True
logger.info("IndicF5 loaded OK")
def _load_mms():
global _mms_model, _mms_tok
from transformers import VitsModel, AutoTokenizer
logger.info(f"Loading MMS-TTS from {_FALLBACK_HUB}")
_mms_tok = AutoTokenizer.from_pretrained(_FALLBACK_HUB)
# Load to CPU — ZeroGPU packs these tensors.
_mms_model = VitsModel.from_pretrained(_FALLBACK_HUB)
logger.info("MMS-TTS loaded OK")
def _get_model():
if not _use_indic and _mms_model is None:
try:
_load_indic()
except Exception as e:
logger.warning(f"IndicF5 load failed ({e}); trying MMS-TTS")
try:
_load_mms()
except Exception as e2:
logger.warning(f"MMS-TTS load failed too ({e2}); will use gTTS fallback")
return _use_indic
# Pre-load to CPU at module scope so ZeroGPU packs the tensors.
try:
_get_model()
except Exception:
pass
def _split(text: str, max_chars: int = 200):
parts = re.split(r"(?<=[.!?।])\s+|\n+", text.strip())
out = []
for p in parts:
p = p.strip()
if not p:
continue
while len(p) > max_chars:
cut = p.rfind(" ", 0, max_chars)
cut = cut if cut > 0 else max_chars
out.append(p[:cut].strip())
p = p[cut:].strip()
out.append(p)
return out or [text.strip()]
def _narrate_indic(ref_wav: str, kannada_text: str) -> tuple[np.ndarray, int]:
model = _indic_model.to("cuda") # ZeroGPU intercepts inside @spaces.GPU
silence_sr = 24_000
silence = np.zeros(int(0.55 * silence_sr), dtype=np.float32)
chunks = []
for sent in _split(kannada_text):
kw = dict(ref_audio_path=ref_wav) if ref_wav and os.path.exists(ref_wav) else {}
audio = model(sent, **kw)
audio = np.asarray(audio, dtype=np.float32)
if audio.size and float(np.max(np.abs(audio))) > 1.0:
audio = audio / 32768.0
if audio.size:
chunks.append(audio)
chunks.append(silence)
return (np.concatenate(chunks) if chunks else np.array([], dtype=np.float32)), silence_sr
def _narrate_mms(kannada_text: str) -> tuple[np.ndarray, int]:
model = _mms_model.to("cuda") # ZeroGPU intercepts inside @spaces.GPU
silence = np.zeros(int(0.55 * MMS_SR), dtype=np.float32)
chunks = []
for sent in _split(kannada_text):
if not sent.strip():
continue
inputs = _mms_tok(sent, return_tensors="pt").to(model.device)
with torch.no_grad():
wav = model(**inputs).waveform
audio = wav.squeeze().cpu().float().numpy()
if audio.size:
chunks.append(audio)
chunks.append(silence)
full = np.concatenate(chunks) if chunks else np.array([], dtype=np.float32)
return full, MMS_SR
def _narrate_gtts(kannada_text: str) -> tuple[np.ndarray, int]:
"""Last-resort: gTTS Kannada (generic Google voice, no GPU needed)."""
import io
import librosa
from gtts import gTTS
logger.info("Using gTTS Kannada fallback")
tts = gTTS(text=kannada_text, lang="kn", slow=True)
fd, mp3_path = tempfile.mkstemp(suffix=".mp3")
os.close(fd)
try:
tts.save(mp3_path)
data, sr = librosa.load(mp3_path, sr=22050, mono=True)
finally:
try:
os.unlink(mp3_path)
except OSError:
pass
return data.astype(np.float32), sr
def narrate_kannada(ref_wav: str, ref_text: str, kannada_text: str,
mood: str = "", energy: float = 0.45) -> str:
"""Narrate Kannada text. Tries: IndicF5 → MMS-TTS → gTTS. Returns WAV path."""
text = (kannada_text or "").strip()
if not text:
raise ValueError("No Kannada text to narrate.")
use_indic = _get_model()
full = np.array([], dtype=np.float32)
sr = MMS_SR
# Tier 1: user's fine-tuned IndicF5
if use_indic:
try:
full, sr = _narrate_indic(ref_wav or "", text)
logger.info("IndicF5 narration OK")
except Exception as e:
logger.warning(f"IndicF5 narration failed ({e}); trying MMS-TTS")
# Tier 2: MMS-TTS-Kan
if not full.size and _mms_model is not None:
try:
full, sr = _narrate_mms(text)
logger.info("MMS-TTS narration OK")
except Exception as e:
logger.warning(f"MMS-TTS narration failed ({e}); trying gTTS")
# Tier 3: gTTS (always works, no GPU needed)
if not full.size:
try:
full, sr = _narrate_gtts(text)
logger.info("gTTS narration OK")
except Exception as e:
raise RuntimeError(f"All Kannada TTS tiers failed. Last error: {e}") from e
if not full.size:
raise RuntimeError("TTS produced no audio.")
peak = float(np.max(np.abs(full)))
if peak > 0:
full = full / peak * 0.92
import soundfile as sf
fd, path = tempfile.mkstemp(prefix="bedtime_kn_", suffix=".wav")
os.close(fd)
sf.write(path, full, sr)
return path