import io import logging import os import wave import threading import httpx log = logging.getLogger(__name__) MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "voices") MODEL_URL = ( "https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/siwis/medium/" "fr_FR-siwis-medium.onnx" ) CONFIG_URL = MODEL_URL + ".json" MODEL_PATH = os.path.join(MODEL_DIR, "fr_FR-siwis-medium.onnx") CONFIG_PATH = MODEL_PATH + ".json" _voice = None _voice_lock = threading.Lock() _piper_available = False def _download(url, path): os.makedirs(os.path.dirname(path), exist_ok=True) with httpx.Client() as client: resp = client.get(url, follow_redirects=True, timeout=120) resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_bytes(): f.write(chunk) def _ensure_model(): if not os.path.exists(MODEL_PATH): _download(MODEL_URL, MODEL_PATH) if not os.path.exists(CONFIG_PATH): _download(CONFIG_URL, CONFIG_PATH) def _init(): global _piper_available try: _ensure_model() from piper import PiperVoice # noqa: F401 _piper_available = True log.info("piper-tts available, voice model at %s", MODEL_PATH) except Exception as e: log.warning("piper-tts unavailable: %s", e) _init() def _get_voice(): global _voice if _voice is not None: return _voice with _voice_lock: if _voice is not None: return _voice from piper import PiperVoice _voice = PiperVoice.load(MODEL_PATH, config_path=CONFIG_PATH) return _voice def synthesize(text: str) -> bytes | None: if not text: return None if not _piper_available: log.warning("piper-tts not available, skipping TTS") return None try: voice = _get_voice() sample_rate = 22050 sample_width = 2 frames = bytearray() for chunk in voice.synthesize(text): sample_rate = chunk.sample_rate sample_width = chunk.sample_width frames.extend(chunk.audio_int16_bytes) if not frames: return None buf = io.BytesIO() with wave.open(buf, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(sample_width) wf.setframerate(sample_rate) wf.writeframes(bytes(frames)) return buf.getvalue() except Exception as e: log.error("TTS synthesis failed: %s", e) return None