""" WaxalTTSEngine — lightweight VITS-based TTS for Sahel-Voice-Lab. Bambara : ynnov/ekodi-bambara-tts-female (VitsModel + AutoTokenizer) Fula : placeholder — returns None until ous-sow/fula-tts is trained """ from __future__ import annotations import logging import os import threading from typing import Optional import numpy as np logger = logging.getLogger(__name__) BAMBARA_TTS_REPO = os.environ.get("BAMBARA_TTS_REPO", "ynnov/ekodi-bambara-tts-female") FULA_TTS_REPO = os.environ.get("FULA_TTS_REPO", "ous-sow/fula-tts") HF_TOKEN = os.environ.get("HF_TOKEN") def generate_pular_tts(text: str) -> None: """ Placeholder for Fula (Pulaar) TTS. Returns None until ous-sow/fula-tts is trained and pushed to the Hub. Run notebooks/train_fula_tts.ipynb on Kaggle T4 to produce the model. """ logger.info("generate_pular_tts: model not yet trained — returning None") return None class WaxalTTSEngine: """Unified TTS engine: Bambara (VITS) + Fula (placeholder).""" def __init__(self) -> None: self._lock = threading.Lock() # Bambara self._bam_model = None self._bam_tokenizer = None self._bam_ready = False self._bam_error: Optional[str] = None # ── Public API ──────────────────────────────────────────────────────────── def synthesize(self, text: str, lang: str) -> Optional[tuple[np.ndarray, int]]: """ Returns (audio_float32, sample_rate) or None if TTS unavailable. Never raises — all errors are logged. """ text = text.strip() if not text: return None try: if lang == "bam": return self._synthesize_bambara(text) elif lang == "ful": return generate_pular_tts(text) else: return None except Exception as exc: logger.error("WaxalTTS.synthesize(%s) unexpected error: %s", lang, exc) return None def get_status(self) -> dict: bam = "ready" if self._bam_ready else ( f"error: {self._bam_error}" if self._bam_error else "loading…" ) return {"bam": bam, "ful": "not trained yet"} def preload(self) -> None: """Start background thread to load the Bambara model.""" threading.Thread(target=self._load_bambara, daemon=True).start() # ── Bambara (ynnov/ekodi-bambara-tts-female, VITS) ─────────────────────── def _load_bambara(self) -> None: try: from transformers import VitsModel, AutoTokenizer logger.info("WaxalTTS: loading Bambara TTS from %s …", BAMBARA_TTS_REPO) tok = AutoTokenizer.from_pretrained(BAMBARA_TTS_REPO, token=HF_TOKEN) mdl = VitsModel.from_pretrained(BAMBARA_TTS_REPO, token=HF_TOKEN) mdl.eval() with self._lock: self._bam_tokenizer = tok self._bam_model = mdl self._bam_ready = True logger.info("WaxalTTS: Bambara TTS ready") except Exception as exc: self._bam_error = str(exc) logger.error("WaxalTTS: Bambara TTS load failed: %s", exc) def _synthesize_bambara(self, text: str) -> Optional[tuple[np.ndarray, int]]: if not self._bam_ready: self._load_bambara() if not self._bam_ready: logger.warning("WaxalTTS: Bambara TTS not ready — %s", self._bam_error) return None try: import torch with self._lock: inputs = self._bam_tokenizer(text, return_tensors="pt") with torch.no_grad(): output = self._bam_model(**inputs) audio = output.waveform[0].cpu().numpy().astype(np.float32) sr = self._bam_model.config.sampling_rate return audio, sr except Exception as exc: logger.error("WaxalTTS: Bambara synthesis failed: %s", exc) self._bam_error = str(exc) self._bam_ready = False return None # ── Utility ─────────────────────────────────────────────────────────────── @staticmethod def audio_to_gradio(audio: np.ndarray, sr: int) -> tuple[int, np.ndarray]: """Convert float32 → int16 tuple that gr.Audio expects.""" pcm = (audio * 32767).clip(-32768, 32767).astype(np.int16) return sr, pcm