"""Tests for TTSEngine."""
import pytest
import numpy as np
from unittest.mock import patch, MagicMock
class TestTTSEngine:
"""Tests for TTSEngine text-to-speech."""
def test_tts_no_auto_load(self, mock_tts_model):
"""TTSEngine does not load model on instantiation (lazy loading)."""
from src.localvoicemode.speech.tts import TTSEngine
# Patch pocket_tts.TTSModel.load_model to track if it's called
with patch("pocket_tts.TTSModel.load_model") as mock_load:
engine = TTSEngine()
# Model should not be loaded yet
assert engine._model is None
# load_model should NOT have been called during __init__
mock_load.assert_not_called()
def test_synthesize_returns_audio(self, mock_tts_model):
"""TTSEngine.synthesize returns audio array."""
from src.localvoicemode.speech.tts import TTSEngine
engine = TTSEngine()
audio, sample_rate = engine.synthesize("Hello world")
assert isinstance(audio, np.ndarray)
assert sample_rate == 24000
def test_lazy_loading(self, mock_tts_model):
"""TTSEngine doesn't load model until first use."""
from src.localvoicemode.speech.tts import TTSEngine
engine = TTSEngine()
assert engine._model is None
def test_ensure_loaded_loads_model(self, mock_tts_model):
"""TTSEngine._ensure_loaded loads the model."""
from src.localvoicemode.speech.tts import TTSEngine
engine = TTSEngine()
assert engine._model is None
engine._ensure_loaded()
assert engine._model is not None
def test_voice_caching(self, mock_tts_model):
"""TTSEngine caches loaded voices."""
from src.localvoicemode.speech.tts import TTSEngine
engine = TTSEngine()
engine._ensure_loaded()
# Load a voice
engine.load_voice(voice_name="test_voice")
# Voice should be cached
assert "test_voice" in engine._voice_states
assert engine._current_voice == "test_voice"
# Loading same voice again should not call get_state_for_audio_prompt again
initial_call_count = mock_tts_model.TTSModel.load_model.return_value.get_state_for_audio_prompt.call_count
engine.load_voice(voice_name="test_voice")
assert (
mock_tts_model.TTSModel.load_model.return_value.get_state_for_audio_prompt.call_count
== initial_call_count
)
def test_load_voice_with_path(self, mock_tts_model, tmp_path):
"""TTSEngine can load voice from file path."""
from src.localvoicemode.speech.tts import TTSEngine
# Create a fake voice file
voice_file = tmp_path / "test_voice.wav"
voice_file.write_bytes(b"fake wav content")
engine = TTSEngine()
engine._ensure_loaded()
engine.load_voice(voice_path=voice_file, voice_name="custom_voice")
assert "custom_voice" in engine._voice_states
assert engine._current_voice == "custom_voice"
def test_synthesize_auto_loads_default_voice(self, mock_tts_model):
"""TTSEngine.synthesize loads default voice if none set."""
from src.localvoicemode.speech.tts import TTSEngine
engine = TTSEngine()
# Don't manually load voice
audio, sr = engine.synthesize("Test text")
# Should have auto-loaded default voice
assert engine._current_voice is not None
assert "default" in engine._voice_states or engine._current_voice in engine._voice_states
class TestTTSEnginePlayback:
"""Tests for TTSEngine audio playback."""
def test_speak_filters_content(self, mock_tts_model, mock_sounddevice, mocker):
"""TTSEngine.speak filters content through TTSFilter."""
from src.localvoicemode.speech.tts import TTSEngine
# Reset the global filter to ensure clean state
import src.localvoicemode.speech.filter as filter_module
filter_module._tts_filter = None
engine = TTSEngine()
# Speak text with code block that should be filtered
engine.speak("Here is code: ```python\nprint('hello')\n```")
# Verify synthesize was called (content was filtered and spoken)
mock_tts_model.TTSModel.load_model.return_value.generate_audio.assert_called()
def test_speak_with_level_callback(self, mock_tts_model, mock_sounddevice, mocker):
"""TTSEngine.speak accepts level callback parameter."""
from src.localvoicemode.speech.tts import TTSEngine
# Reset the global filter
import src.localvoicemode.speech.filter as filter_module
filter_module._tts_filter = None
engine = TTSEngine()
levels_received = []
def callback(level):
levels_received.append(level)
# Mock _play_with_levels to avoid the actual playback loop
mock_play = mocker.patch.object(engine, "_play_with_levels")
# Also mock synthesize to return known audio
mock_audio = np.zeros(24000, dtype=np.float32)
mocker.patch.object(engine, "synthesize", return_value=(mock_audio, 24000))
engine.speak("Hello", level_callback=callback)
# Verify _play_with_levels was called with the callback
mock_play.assert_called_once()
# The callback should be passed as the third argument
assert mock_play.call_args[0][2] is callback
def test_speak_silent_mode_does_nothing(self, mock_tts_model, mocker):
"""TTSEngine.speak does nothing in silent mode."""
from src.localvoicemode.speech.tts import TTSEngine
from src.localvoicemode.speech.filter import SpeechMode, get_tts_filter
# Reset and set to silent mode
import src.localvoicemode.speech.filter as filter_module
filter_module._tts_filter = None
get_tts_filter().set_mode(SpeechMode.SILENT)
engine = TTSEngine()
# Mock synthesize to verify it's not called
mock_synthesize = mocker.patch.object(engine, "synthesize")
engine.speak("Hello world")
# Synthesize should not be called in silent mode
mock_synthesize.assert_not_called()
# Reset mode for other tests
get_tts_filter().set_mode(SpeechMode.SPEAK_ALL)