"""Voice layer: STT via faster-whisper, TTS via piper, recording via sounddevice.""" import io import wave from pathlib import Path from typing import Optional from infj_bot.core.config import PROJECT_ROOT VOICE_DIR = PROJECT_ROOT / "voices" DEFAULT_VOICE = VOICE_DIR / "en_US-lessac-medium.onnx" MAX_AUDIO_BYTES = 50 * 1024 * 1024 # Lazy-loaded models _whisper_model = None _piper_voice = None def _get_whisper(): global _whisper_model if _whisper_model is None: from faster_whisper import WhisperModel _whisper_model = WhisperModel("base", device="cpu", compute_type="int8") return _whisper_model def _get_piper(): global _piper_voice if _piper_voice is None: from piper import PiperVoice voice_path = str(DEFAULT_VOICE) if DEFAULT_VOICE.exists() else None if voice_path is None: raise RuntimeError( f"No Piper voice found at {DEFAULT_VOICE}. Run: mkdir -p voices && curl ..." ) _piper_voice = PiperVoice.load(voice_path) return _piper_voice def _resolve_audio_path(path: str, must_exist: bool = True) -> Path: target = Path(path).expanduser() if not target.is_absolute(): target = PROJECT_ROOT / target target = target.resolve() try: target.relative_to(Path.home().resolve()) except ValueError: raise PermissionError( f"Audio path {path} is outside the allowed home directory." ) if must_exist: if not target.exists(): raise FileNotFoundError(path) if target.stat().st_size > MAX_AUDIO_BYTES: raise ValueError( f"Audio file is too large; max is {MAX_AUDIO_BYTES} bytes." ) return target def record_audio( duration: float = 5.0, sample_rate: int = 16000, channels: int = 1 ) -> bytes: """Record audio from the microphone and return WAV bytes.""" import sounddevice as sd frames = int(duration * sample_rate) recording = sd.rec(frames, samplerate=sample_rate, channels=channels, dtype="int16") sd.wait() buffer = io.BytesIO() with wave.open(buffer, "wb") as wav: wav.setnchannels(channels) wav.setsampwidth(2) wav.setframerate(sample_rate) wav.writeframes(recording.tobytes()) return buffer.getvalue() def record_to_file(path: str, duration: float = 5.0): """Record audio from the microphone to a WAV file.""" target = _resolve_audio_path(path, must_exist=False) target.parent.mkdir(parents=True, exist_ok=True) wav_bytes = record_audio(duration=duration) target.write_bytes(wav_bytes) def transcribe(audio_path: str) -> str: """Transcribe an audio file to text.""" resolved_path = _resolve_audio_path(audio_path) model = _get_whisper() segments, _info = model.transcribe(str(resolved_path), beam_size=5) return " ".join(segment.text for segment in segments).strip() def synthesize(text: str) -> bytes: """Synthesize text to WAV bytes.""" voice = _get_piper() buffer = io.BytesIO() with wave.open(buffer, "wb") as wav: voice.synthesize_wav(text, wav) return buffer.getvalue() def synthesize_to_file(text: str, path: str): """Synthesize text to a WAV file.""" out_path = _resolve_audio_path(path, must_exist=False) out_path.parent.mkdir(parents=True, exist_ok=True) voice = _get_piper() with wave.open(str(out_path), "wb") as wav: voice.synthesize_wav(text, wav) def listen_and_transcribe( audio_path: str, wake_word: str = "companion" ) -> Optional[str]: """Transcribe audio and return text only if wake word is present.""" text = transcribe(audio_path) if wake_word.lower() in text.lower(): lowered = text.lower() idx = lowered.find(wake_word.lower()) if idx >= 0: text = text[idx + len(wake_word) :].strip() return text return None def speak(text: str, output_path: Optional[str] = None) -> Optional[bytes]: """Speak text. Returns WAV bytes if no path given, else writes to path.""" if output_path: synthesize_to_file(text, output_path) return None return synthesize(text) def play_audio(wav_bytes: bytes): """Play WAV bytes through the default audio output.""" import sounddevice as sd import soundfile as sf buffer = io.BytesIO(wav_bytes) data, sr = sf.read(buffer, dtype="int16") sd.play(data, sr) sd.wait() if __name__ == "__main__": print("Voice module ready.") print("Whisper model: base (CPU, int8)") print(f"Piper voice: {DEFAULT_VOICE}")