test_tts_stability.pyā¢12.1 kB
#!/usr/bin/env python
"""
Test TTS stability and HTTP client lifecycle.
These tests are based on the debug script that identified hanging issues.
They test:
- Multiple TTS cycles without hanging
- Proper HTTP client cleanup
- Connection pooling behavior
- Memory management
"""
import asyncio
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, Mock
import pytest
import httpx
# Set required environment variables before imports
os.environ['OPENAI_API_KEY'] = os.environ.get('OPENAI_API_KEY', 'test-key')
# Import from the core module
from voice_mode.core import text_to_speech, get_openai_clients, cleanup, save_debug_file
class TestTTSStability:
"""Test TTS stability under repeated use"""
@pytest.fixture
def mock_tts_response(self):
"""Create a mock TTS response"""
response = MagicMock()
response.content = b"fake audio data" * 1000 # ~30KB of fake audio
response.read = AsyncMock(return_value=response.content)
response.__aenter__ = AsyncMock(return_value=response)
response.__aexit__ = AsyncMock(return_value=None)
return response
@pytest.fixture
def mock_openai_client(self, mock_tts_response):
"""Create a mock OpenAI client with proper async context manager"""
client = MagicMock()
# Create a proper async context manager mock
async def mock_create(*args, **kwargs):
# Return the mock response that has __aenter__ and __aexit__
return mock_tts_response
# Create a mock streaming response handler
streaming_handler = MagicMock()
streaming_handler.create = mock_create
# Set up the client structure
client.audio.speech.with_streaming_response = streaming_handler
# Add HTTP client for cleanup testing
http_client = AsyncMock()
http_client.aclose = AsyncMock()
client._client = http_client
return client
@pytest.mark.skip(reason="Need to refactor for lazy imports")
@pytest.mark.asyncio
async def test_multiple_tts_cycles(self, mock_openai_client):
"""Test multiple TTS cycles to ensure no hanging"""
# Create mock clients dict
openai_clients = {'tts': mock_openai_client}
# Mock dependencies
with patch('voice_mode.core.AudioSegment') as mock_audio, \
patch('voice_mode.core.logger') as mock_logger:
# Mock audio processing
mock_audio_instance = MagicMock()
mock_audio_instance.channels = 1
mock_audio_instance.frame_rate = 24000
mock_audio_instance.get_array_of_samples.return_value = [0] * 1000
mock_audio.from_mp3.return_value = mock_audio_instance
# Mock sounddevice
mock_sd.play = MagicMock()
mock_sd.wait = MagicMock()
# Run multiple cycles
for i in range(5):
result, metrics = await text_to_speech(
text=f"Test message number {i+1}",
openai_clients=openai_clients,
tts_model="tts-1",
tts_voice="nova",
tts_base_url="https://api.openai.com/v1",
debug=False
)
assert result is True
assert metrics is not None
assert 'generation' in metrics
assert 'playback' in metrics
# Verify TTS was called
assert mock_openai_client.audio.speech.with_streaming_response.create.called
# Reset for next cycle
mock_openai_client.audio.speech.with_streaming_response.create.reset_mock()
# Verify no resource leaks (all calls completed)
assert mock_openai_client.audio.speech.with_streaming_response.create.call_count == 0
@pytest.mark.asyncio
async def test_http_client_cleanup(self, mock_openai_client):
"""Test that HTTP clients are properly cleaned up"""
# Set up mock clients
openai_clients = {
'stt': mock_openai_client,
'tts': mock_openai_client
}
# Run cleanup
await cleanup(openai_clients)
# Verify HTTP clients were closed
assert mock_openai_client._client.aclose.called
assert mock_openai_client._client.aclose.call_count == 2 # Once for STT, once for TTS
@pytest.mark.skip(reason="httpx.Timeout API changed")
@pytest.mark.asyncio
async def test_tts_with_connection_pooling(self):
"""Test TTS with connection pooling configuration"""
with patch('voice_mode.core.AsyncOpenAI') as mock_openai_class, \
patch('voice_mode.core.httpx.AsyncClient') as mock_http_client:
# Call get_openai_clients
clients = get_openai_clients(
api_key="test-key",
stt_base_url="https://api.openai.com/v1",
tts_base_url="https://api.openai.com/v1"
)
# Verify HTTP client was created with proper config
mock_http_client.assert_called()
call_args = mock_http_client.call_args[1]
# Check timeout configuration
assert isinstance(call_args['timeout'], httpx.Timeout)
assert call_args['timeout'].timeout == 30.0
assert call_args['timeout'].connect == 5.0
# Check connection limits
assert isinstance(call_args['limits'], httpx.Limits)
assert call_args['limits'].max_keepalive_connections == 5
assert call_args['limits'].max_connections == 10
@pytest.mark.skip(reason="Complex async context manager mocking - error handling tested elsewhere")
@pytest.mark.asyncio
async def test_tts_error_handling(self):
"""Test TTS error handling and recovery"""
# This test is difficult to mock properly because text_to_speech uses async context managers
# Error handling is tested in the converse critical path tests instead
pass
@pytest.mark.skip(reason="Need to refactor for lazy imports")
@pytest.mark.asyncio
async def test_debug_logging(self, mock_openai_client, tmp_path):
"""Test debug logging functionality"""
# Set debug mode
debug_dir = tmp_path / "voice-mode_recordings"
debug_dir.mkdir(exist_ok=True)
# Test saving debug file
test_data = b"test audio data"
debug_path = save_debug_file(test_data, "test", "mp3", debug_dir, debug=True)
assert debug_path is not None
assert Path(debug_path).exists()
assert Path(debug_path).read_bytes() == test_data
# Test debug mode with TTS
openai_clients = {'tts': mock_openai_client}
with patch('voice_mode.core.AudioSegment') as mock_audio, \
patch('voice_mode.core.sd') as mock_sd:
# Mock audio processing
mock_audio_instance = MagicMock()
mock_audio_instance.channels = 1
mock_audio_instance.frame_rate = 24000
mock_audio_instance.get_array_of_samples.return_value = [0] * 1000
mock_audio.from_mp3.return_value = mock_audio_instance
# Mock sounddevice
mock_sd.play = MagicMock()
mock_sd.wait = MagicMock()
# Test with debug enabled
result, metrics = await text_to_speech(
text="Debug test",
openai_clients=openai_clients,
tts_model="tts-1",
tts_voice="nova",
tts_base_url="https://api.openai.com/v1",
debug=True,
debug_dir=debug_dir
)
assert result is True
assert metrics is not None
@pytest.mark.asyncio
async def test_trace_logging(self, tmp_path):
"""Test trace logging functionality"""
os.environ['VOICE_MODE_DEBUG'] = 'trace'
trace_file = tmp_path / "voice_mode_trace.log"
# This test would require actually importing the module with trace enabled
# For now, we'll test the trace file creation logic
assert os.environ['VOICE_MODE_DEBUG'] == 'trace'
# In actual implementation, trace logging would create the file
# We'll just verify the environment is set correctly
assert os.getenv('VOICE_MODE_DEBUG', '').lower() == 'trace'
class TestMemoryManagement:
"""Test memory management and garbage collection"""
@pytest.mark.asyncio
async def test_garbage_collection_on_cleanup(self):
"""Test that garbage collection runs during cleanup"""
with patch('voice_mode.core.gc') as mock_gc:
mock_gc.collect.return_value = 42 # Number of objects collected
await cleanup({})
# Verify garbage collection was called
mock_gc.collect.assert_called_once()
class TestAudioFileHandling:
"""Test audio file creation and cleanup"""
@pytest.mark.skip(reason="Missing fixture - need to refactor")
@pytest.mark.asyncio
async def test_temporary_file_cleanup(self, mock_openai_client):
"""Test that temporary files are cleaned up"""
openai_clients = {'tts': mock_openai_client}
temp_files_created = []
original_tempfile = tempfile.NamedTemporaryFile
def track_tempfile(*args, **kwargs):
"""Track temporary files created"""
tmp = original_tempfile(*args, **kwargs)
temp_files_created.append(tmp.name)
return tmp
with patch('voice_mode.core.AudioSegment') as mock_audio, \
patch('voice_mode.core.sd') as mock_sd, \
patch('voice_mode.core.tempfile.NamedTemporaryFile', track_tempfile), \
patch('voice_mode.core.os.unlink') as mock_unlink:
# Mock audio processing
mock_audio_instance = MagicMock()
mock_audio_instance.channels = 1
mock_audio_instance.frame_rate = 24000
mock_audio_instance.get_array_of_samples.return_value = [0] * 1000
mock_audio.from_mp3.return_value = mock_audio_instance
# Mock sounddevice
mock_sd.play = MagicMock()
mock_sd.wait = MagicMock()
result, metrics = await text_to_speech(
text="Test",
openai_clients=openai_clients,
tts_model="tts-1",
tts_voice="nova",
tts_base_url="https://api.openai.com/v1",
debug=False
)
# Verify temporary file was created and cleaned up
assert len(temp_files_created) > 0
mock_unlink.assert_called()
# Run specific debug scenario tests
@pytest.mark.asyncio
async def test_debug_scenario_reproduction():
"""Reproduce the exact scenario from test_voice_debug.py"""
from openai import AsyncOpenAI
# This test requires actual API key to fully reproduce
api_key = os.getenv("OPENAI_API_KEY")
if not api_key or api_key == "test-key":
pytest.skip("Requires real OPENAI_API_KEY for integration test")
client = AsyncOpenAI(api_key=api_key)
try:
# Test a single cycle (not 5 to avoid rate limits in tests)
response = await client.audio.speech.create(
model="tts-1",
input="Test message",
voice="nova",
response_format="mp3"
)
assert len(response.content) > 0
# Clean up
await client.close()
except Exception as e:
pytest.skip(f"API test failed: {e}")