"""Supertonic TTS - lightning-fast, on-device, ~99M-param ONNX voices. Each suspect gets a distinct preset voice (M1-M5 / F1-F5). Lazily loaded and excluded from unit coverage (needs the supertonic package + ONNX assets). Falls back to unavailable so the game degrades to text-only. """ from __future__ import annotations from pathlib import Path from ..schemas.suspect import VoiceAssignment # Males 0-4, females 5-9, matching tts.assignment.assign_voice's gendered speaker_id. _VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") class SupertonicProvider: # pragma: no cover - requires the supertonic package + models is_local = True def __init__(self) -> None: self.available = False self._tts = None self._styles: dict[str, object] = {} try: from supertonic import TTS self._tts = TTS(auto_download=True) self.available = True except Exception: self.available = False def _style(self, name: str): if name not in self._styles: self._styles[name] = self._tts.get_voice_style(voice_name=name) # type: ignore[union-attr] return self._styles[name] def synth_to_file(self, text: str, voice: VoiceAssignment | None, out_path: Path) -> Path | None: if not self.available or self._tts is None or not text.strip(): return None name = _VOICES[(voice.speaker_id if voice else 0) % len(_VOICES)] # length_scale (0.95-1.15) -> a gentle per-suspect speed variation around 1.0. speed = round(2.05 - (voice.length_scale if voice else 1.05), 2) speed = min(1.2, max(0.85, speed)) try: wav, _ = self._tts.synthesize( # type: ignore[union-attr] text=text, lang="en", voice_style=self._style(name), total_steps=6, speed=speed ) out_path.parent.mkdir(parents=True, exist_ok=True) self._tts.save_audio(wav, str(out_path)) # type: ignore[union-attr] return out_path except Exception: return None