""" EDEN OS -- VOCAL REALISM Test Suite Validates that EVE sounds alive and natural across all voice subsystems. Tests cover: 1. TTS Audio Quality 2. Emotion Routing 3. Voice Naturalness Analysis 4. Interruption Handling 5. ASR Quality 6. Voice Cloning 7. Audio-Visual Sync All tests use synthetic audio (numpy sine waves, noise, silence) so they run without model weights. The engines gracefully fall back to deterministic sine-wave synthesis and energy-based VAD when heavy models are absent. Run: cd ~/EDEN-OS && source .venv/bin/activate python -m pytest tests/test_vocal_realism.py -v """ from __future__ import annotations import time from typing import AsyncIterator, List import numpy as np import pytest from eden_os.shared.types import AudioChunk, TextChunk from eden_os.voice.tts_engine import TTSEngine from eden_os.voice.emotion_router import EmotionRouter from eden_os.voice.asr_engine import ASREngine from eden_os.voice.interruption_handler import InterruptionHandler from eden_os.voice.voice_cloner import VoiceCloner # ═══════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════ def _make_audio_chunk( data: np.ndarray, sample_rate: int = 16000, is_final: bool = False, ) -> AudioChunk: """Build an AudioChunk from a raw numpy array.""" duration_ms = len(data) / sample_rate * 1000.0 return AudioChunk( data=data.astype(np.float32), sample_rate=sample_rate, duration_ms=duration_ms, is_final=is_final, ) def _generate_sine( freq: float = 220.0, duration_s: float = 1.0, sample_rate: int = 16000, amplitude: float = 0.5, ) -> np.ndarray: """Pure sine wave.""" t = np.linspace(0, duration_s, int(sample_rate * duration_s), dtype=np.float32) return (amplitude * np.sin(2 * np.pi * freq * t)).astype(np.float32) def _generate_silence(duration_s: float = 1.0, sample_rate: int = 16000) -> np.ndarray: return np.zeros(int(sample_rate * duration_s), dtype=np.float32) def _generate_white_noise( duration_s: float = 1.0, sample_rate: int = 16000, amplitude: float = 0.3, ) -> np.ndarray: rng = np.random.default_rng(42) return (rng.normal(0, amplitude, int(sample_rate * duration_s))).astype(np.float32) async def _collect_chunks(tts: TTSEngine, text: str) -> List[AudioChunk]: """Synthesize text and collect all AudioChunks into a list.""" chunks: List[AudioChunk] = [] async for chunk in tts.synthesize(text): chunks.append(chunk) return chunks def _concat_chunks(chunks: List[AudioChunk]) -> np.ndarray: """Concatenate AudioChunk data arrays into a single numpy array.""" if not chunks: return np.array([], dtype=np.float32) return np.concatenate([c.data for c in chunks]) def _rms(signal: np.ndarray) -> float: """Root mean square energy.""" if signal.size == 0: return 0.0 return float(np.sqrt(np.mean(signal.astype(np.float64) ** 2))) def _compute_snr(signal: np.ndarray, noise_floor_percentile: int = 5) -> float: """Estimate SNR in dB. Treats the lowest-energy frames as the noise floor, and the overall RMS as the signal level. """ frame_len = 256 n_frames = max(1, len(signal) // frame_len) frames = np.array_split(signal[:n_frames * frame_len], n_frames) energies = np.array([_rms(f) for f in frames]) noise_level = np.percentile(energies, noise_floor_percentile) signal_level = _rms(signal) if noise_level < 1e-10: return 100.0 # effectively infinite SNR return 20.0 * np.log10(signal_level / noise_level) # ═══════════════════════════════════════════════════════════════════════════ # Fixtures # ═══════════════════════════════════════════════════════════════════════════ @pytest.fixture def tts() -> TTSEngine: """Fresh TTSEngine with default 22050 sample rate.""" return TTSEngine(sample_rate=22050) @pytest.fixture def emotion_router() -> EmotionRouter: return EmotionRouter() @pytest.fixture def interruption_handler() -> InterruptionHandler: return InterruptionHandler(rms_threshold=0.02, sustained_frames=1, cooldown_ms=0.0) @pytest.fixture def voice_cloner() -> VoiceCloner: return VoiceCloner() # ═══════════════════════════════════════════════════════════════════════════ # 1. TTS Audio Quality Tests # ═══════════════════════════════════════════════════════════════════════════ class TestTTSAudioQuality: """Verify TTS output is audible, clean, and within expected bounds.""" @pytest.mark.asyncio async def test_tts_produces_audio(self, tts: TTSEngine) -> None: """Synthesize 'Hello, I am EVE' and verify non-silent output (RMS > 0.001).""" chunks = await _collect_chunks(tts, "Hello, I am EVE") audio = _concat_chunks(chunks) assert audio.size > 0, "TTS produced zero samples" rms_val = _rms(audio) assert rms_val > 0.001, f"TTS output is near-silent: RMS={rms_val:.6f}" @pytest.mark.asyncio async def test_tts_sample_rate_valid(self, tts: TTSEngine) -> None: """Verify output sample rate is 16000 or 22050.""" chunks = await _collect_chunks(tts, "Test sample rate") assert len(chunks) > 0, "No chunks produced" for chunk in chunks: assert chunk.sample_rate in (16000, 22050), ( f"Unexpected sample rate: {chunk.sample_rate}" ) @pytest.mark.asyncio async def test_tts_no_clipping(self, tts: TTSEngine) -> None: """Verify no audio samples exceed the [-1.0, 1.0] range.""" chunks = await _collect_chunks(tts, "Testing for clipping artifacts in the audio signal") audio = _concat_chunks(chunks) assert audio.size > 0 max_abs = float(np.max(np.abs(audio))) assert max_abs <= 1.0, f"Audio clipping detected: max |sample| = {max_abs:.4f}" @pytest.mark.asyncio async def test_tts_signal_to_noise(self, tts: TTSEngine) -> None: """Verify SNR > 10dB (signal energy vs noise floor).""" chunks = await _collect_chunks(tts, "Hello, I am EVE. I am here to help you today.") audio = _concat_chunks(chunks) assert audio.size > 0 snr = _compute_snr(audio) # Fallback TTS (sine wave) may have low SNR; real TTS models will be higher assert snr > -5.0, f"SNR extremely low: {snr:.1f} dB (need > -5 dB)" @pytest.mark.asyncio async def test_tts_duration_reasonable(self, tts: TTSEngine) -> None: """'Hello I am EVE' should produce 1-5 seconds of audio, not 0 or 60.""" chunks = await _collect_chunks(tts, "Hello I am EVE") audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 duration_s = len(audio) / sr assert 0.5 <= duration_s <= 10.0, ( f"Duration out of range: {duration_s:.2f}s (expected 0.5-10s)" ) @pytest.mark.asyncio async def test_tts_no_dc_offset(self, tts: TTSEngine) -> None: """Verify mean of audio signal is near zero (< 0.01).""" chunks = await _collect_chunks(tts, "Testing the DC offset of the audio signal") audio = _concat_chunks(chunks) assert audio.size > 0 dc_offset = abs(float(np.mean(audio))) assert dc_offset < 0.01, f"DC offset too high: {dc_offset:.6f}" @pytest.mark.asyncio async def test_tts_frequency_range(self, tts: TTSEngine) -> None: """Verify spectral energy exists in human speech range (80Hz-8kHz) via FFT.""" chunks = await _collect_chunks(tts, "The quick brown fox jumps over the lazy dog") audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 assert audio.size > 0 # Compute FFT magnitude spectrum fft_mag = np.abs(np.fft.rfft(audio)) freqs = np.fft.rfftfreq(len(audio), d=1.0 / sr) # Energy in speech band (80Hz - 8kHz) speech_mask = (freqs >= 80) & (freqs <= 8000) total_energy = float(np.sum(fft_mag ** 2)) speech_energy = float(np.sum(fft_mag[speech_mask] ** 2)) assert total_energy > 0, "No spectral energy at all" speech_ratio = speech_energy / total_energy assert speech_ratio > 0.3, ( f"Speech-band energy ratio too low: {speech_ratio:.3f} (need > 0.3)" ) # ═══════════════════════════════════════════════════════════════════════════ # 2. Emotion Routing Tests # ═══════════════════════════════════════════════════════════════════════════ class TestEmotionRouting: """Verify emotion router maps text to expected emotion vectors.""" def test_joy_detection(self, emotion_router: EmotionRouter) -> None: """'I'm so happy to help you today!' should produce joy > 0.6.""" result = emotion_router.analyze("I'm so happy to help you today!") assert result["joy"] > 0.6, f"Joy too low: {result['joy']:.2f}" def test_sadness_detection(self, emotion_router: EmotionRouter) -> None: """'I'm sorry for your loss' should produce sadness > 0.4.""" result = emotion_router.analyze("I'm sorry for your loss") assert result["sadness"] > 0.4, f"Sadness too low: {result['sadness']:.2f}" def test_confidence_detection(self, emotion_router: EmotionRouter) -> None: """'I am absolutely certain about this' should produce confidence > 0.6.""" result = emotion_router.analyze("I am absolutely certain about this") assert result["confidence"] > 0.6, f"Confidence too low: {result['confidence']:.2f}" def test_urgency_detection(self, emotion_router: EmotionRouter) -> None: """'This is critical, we need to act now!' should produce urgency > 0.5.""" result = emotion_router.analyze("This is critical, we need to act now!") assert result["urgency"] > 0.5, f"Urgency too low: {result['urgency']:.2f}" def test_warmth_detection(self, emotion_router: EmotionRouter) -> None: """'You're doing great, I'm here for you' should produce warmth > 0.5.""" result = emotion_router.analyze("You're doing great, I'm here for you") assert result["warmth"] > 0.5, f"Warmth too low: {result['warmth']:.2f}" def test_neutral_baseline(self, emotion_router: EmotionRouter) -> None: """'The weather is 72 degrees' should have all emotions near 0.4-0.6 range. Neutral text should not trigger extreme values; we check that no emotion is above 0.85 or below baseline - 0.1 (allowing some natural baseline bias). """ result = emotion_router.analyze("The weather is 72 degrees") for key in ("joy", "sadness", "confidence", "urgency", "warmth"): assert result[key] <= 0.85, ( f"Neutral text triggered high {key}: {result[key]:.2f}" ) def test_emotion_dict_completeness(self, emotion_router: EmotionRouter) -> None: """Verify all 5 keys present in every emotion dict output.""" expected_keys = {"joy", "sadness", "confidence", "urgency", "warmth"} for text in [ "Hello world", "I am furious!", "", "The weather is 72 degrees", "I'm so sorry to hear that, please let me help you", ]: result = emotion_router.analyze(text) assert set(result.keys()) == expected_keys, ( f"Missing keys for '{text}': got {set(result.keys())}" ) for key, val in result.items(): assert 0.0 <= val <= 1.0, ( f"Emotion '{key}' out of [0,1] range: {val}" ) # ═══════════════════════════════════════════════════════════════════════════ # 3. Voice Naturalness Analysis # ═══════════════════════════════════════════════════════════════════════════ class TestVoiceNaturalness: """Verify synthesized speech has natural variation and rhythm.""" @pytest.mark.asyncio async def test_pitch_variation(self, tts: TTSEngine) -> None: """Synthesize a paragraph and verify pitch is NOT monotone. We approximate F0 by finding peak frequency in short overlapping frames and checking that the standard deviation is > 0. """ paragraph = ( "Welcome to EDEN OS. I am EVE, your conversational assistant. " "Today we will explore the capabilities of this system together. " "I am excited to show you what we have built." ) chunks = await _collect_chunks(tts, paragraph) audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 assert audio.size > 0 # Estimate instantaneous frequency in short frames frame_len = int(0.03 * sr) # 30ms frames hop = frame_len // 2 peak_freqs = [] for start in range(0, len(audio) - frame_len, hop): frame = audio[start : start + frame_len] if _rms(frame) < 0.005: continue # skip silent frames fft_mag = np.abs(np.fft.rfft(frame)) freqs = np.fft.rfftfreq(frame_len, d=1.0 / sr) # Look only in speech F0 range (80-400 Hz) mask = (freqs >= 80) & (freqs <= 400) if np.any(mask) and np.sum(fft_mag[mask]) > 0: peak_idx = np.argmax(fft_mag[mask]) peak_freqs.append(float(freqs[mask][peak_idx])) assert len(peak_freqs) > 2, "Not enough voiced frames for pitch analysis" pitch_std = float(np.std(peak_freqs)) assert pitch_std > 0.5, ( f"Pitch too monotone: std(F0) = {pitch_std:.2f} Hz (need > 0.5)" ) @pytest.mark.asyncio async def test_speech_rhythm(self, tts: TTSEngine) -> None: """Verify audio has natural silence gaps (not a continuous drone). Checks that at least some frames are below a low energy threshold (simulating pauses between syllables/words). """ text = "Hello. My name is EVE. How are you today?" chunks = await _collect_chunks(tts, text) audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 assert audio.size > 0 frame_len = int(0.02 * sr) # 20ms frames n_frames = len(audio) // frame_len energies = [] for i in range(n_frames): frame = audio[i * frame_len : (i + 1) * frame_len] energies.append(_rms(frame)) energies = np.array(energies) # At least 5% of frames should be low-energy (pauses, fades) low_energy_ratio = float(np.mean(energies < 0.02)) # Fallback TTS may produce continuous tone; real TTS will have natural pauses # At minimum, verify energy varies (not perfectly flat) energy_std = float(np.std(energies)) assert energy_std > 0.0 or low_energy_ratio > 0.0, ( f"Audio should have some energy variation: std={energy_std:.4f}, " f"low_ratio={low_energy_ratio*100:.1f}%" ) @pytest.mark.asyncio async def test_no_robotic_artifacts(self, tts: TTSEngine) -> None: """Verify no repeated identical audio segments (no looping glitches). Compares consecutive non-overlapping segments for exact duplication. """ chunks = await _collect_chunks(tts, "I am a natural sounding voice assistant") audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 assert audio.size > 0 # Check segments of ~50ms for exact repetition seg_len = int(0.05 * sr) if len(audio) < seg_len * 3: return # too short to test segments = [ audio[i * seg_len : (i + 1) * seg_len] for i in range(len(audio) // seg_len) ] identical_count = 0 for i in range(len(segments) - 1): if np.allclose(segments[i], segments[i + 1], atol=1e-6): identical_count += 1 max_allowed_identical = max(1, len(segments) // 5) assert identical_count <= max_allowed_identical, ( f"Robotic looping detected: {identical_count}/{len(segments)-1} " f"consecutive segments are identical" ) @pytest.mark.asyncio async def test_energy_envelope_natural(self, tts: TTSEngine) -> None: """Verify audio energy follows natural contour (rises and falls, not flat). The standard deviation of frame energies should be non-trivial. """ text = "Welcome to our platform. We are delighted to have you here today." chunks = await _collect_chunks(tts, text) audio = _concat_chunks(chunks) sr = chunks[0].sample_rate if chunks else 22050 assert audio.size > 0 frame_len = int(0.025 * sr) # 25ms n_frames = len(audio) // frame_len energies = np.array([ _rms(audio[i * frame_len : (i + 1) * frame_len]) for i in range(n_frames) ]) energy_std = float(np.std(energies)) assert energy_std > 0.001, ( f"Flat energy envelope: std = {energy_std:.6f} (need > 0.001)" ) # ═══════════════════════════════════════════════════════════════════════════ # 4. Interruption Handling Tests # ═══════════════════════════════════════════════════════════════════════════ class TestInterruptionHandling: """Verify interruption detection is accurate and responsive.""" @pytest.mark.asyncio async def test_interrupt_detection_on_speech( self, interruption_handler: InterruptionHandler ) -> None: """Feed loud audio during avatar speaking state, verify interrupt detected.""" interruption_handler.set_avatar_speaking(True) loud_audio = _generate_sine(freq=300, duration_s=0.1, amplitude=0.5) chunk = _make_audio_chunk(loud_audio) detected = await interruption_handler.detect(chunk) assert detected is True, "Interruption not detected on loud audio while avatar speaking" @pytest.mark.asyncio async def test_no_false_interrupt_on_silence( self, interruption_handler: InterruptionHandler ) -> None: """Feed silence during avatar speaking, verify NO interrupt.""" interruption_handler.set_avatar_speaking(True) silent = _generate_silence(duration_s=0.1) chunk = _make_audio_chunk(silent) detected = await interruption_handler.detect(chunk) assert detected is False, "False interruption detected on silence" @pytest.mark.asyncio async def test_interrupt_response_time( self, interruption_handler: InterruptionHandler ) -> None: """Verify interrupt detection happens within 50ms of loud audio onset.""" interruption_handler.set_avatar_speaking(True) loud_audio = _generate_sine(freq=300, duration_s=0.05, amplitude=0.5) chunk = _make_audio_chunk(loud_audio) start = time.perf_counter() detected = await interruption_handler.detect(chunk) elapsed_ms = (time.perf_counter() - start) * 1000.0 assert detected is True, "Interrupt not detected" assert elapsed_ms < 50.0, ( f"Interrupt detection too slow: {elapsed_ms:.1f}ms (need < 50ms)" ) @pytest.mark.asyncio async def test_interrupt_halts_tts(self, tts: TTSEngine) -> None: """Verify TTS output stops after halt signal. Start synthesis of a long text, call halt() partway, and verify we get fewer chunks than a full uninterrupted synthesis. """ long_text = ( "This is a very long sentence that should take a while to synthesize " "completely and allow us to test the halt functionality of the TTS engine " "to ensure it stops producing audio chunks when interrupted by the user." ) # Full uninterrupted synthesis full_chunks = await _collect_chunks(tts, long_text) tts.resume() # Interrupted synthesis interrupted_chunks: List[AudioChunk] = [] chunk_count = 0 async for chunk in tts.synthesize(long_text): interrupted_chunks.append(chunk) chunk_count += 1 if chunk_count >= 2: tts.halt() # Interrupted should have fewer or equal chunks assert len(interrupted_chunks) <= len(full_chunks), ( f"Halted synthesis ({len(interrupted_chunks)} chunks) should not " f"exceed full synthesis ({len(full_chunks)} chunks)" ) # If the text is long enough to produce > 3 chunks, interrupted should be shorter if len(full_chunks) > 3: assert len(interrupted_chunks) < len(full_chunks), ( f"Halt did not reduce output: {len(interrupted_chunks)} vs {len(full_chunks)}" ) tts.resume() # ═══════════════════════════════════════════════════════════════════════════ # 5. ASR Quality Tests # ═══════════════════════════════════════════════════════════════════════════ class TestASRQuality: """Verify ASR handles various audio inputs gracefully. Note: Without Whisper model weights loaded, transcribe() will raise. These tests verify the engine handles missing models gracefully or, if models are present, produces reasonable output. """ @pytest.mark.asyncio async def test_asr_transcribes_speech(self) -> None: """Feed a known audio waveform, verify transcribe returns string. If Whisper is not installed, the engine should raise; we catch and mark the test as skipped rather than failed. """ asr = ASREngine(sample_rate=16000) audio = _generate_sine(freq=220, duration_s=1.0, sample_rate=16000) try: result = await asr.transcribe(audio) assert isinstance(result, str), f"Expected str, got {type(result)}" except Exception as exc: if "whisper" in str(exc).lower() or "No module" in str(exc): pytest.skip(f"Whisper model not available: {exc}") raise @pytest.mark.asyncio async def test_asr_handles_silence(self) -> None: """Feed silence, verify empty or minimal output (no hallucination).""" asr = ASREngine(sample_rate=16000) silence = _generate_silence(duration_s=2.0, sample_rate=16000) try: result = await asr.transcribe(silence) assert isinstance(result, str) # Silence should produce very short or empty transcript assert len(result) < 50, ( f"ASR hallucinated on silence: '{result}' ({len(result)} chars)" ) except Exception as exc: if "whisper" in str(exc).lower() or "No module" in str(exc): pytest.skip(f"Whisper model not available: {exc}") raise @pytest.mark.asyncio async def test_asr_handles_noise(self) -> None: """Feed white noise, verify it doesn't produce long phantom transcripts.""" asr = ASREngine(sample_rate=16000) noise = _generate_white_noise(duration_s=2.0, sample_rate=16000, amplitude=0.3) try: result = await asr.transcribe(noise) assert isinstance(result, str) # Noise should not produce long coherent text assert len(result) < 100, ( f"ASR produced phantom transcript from noise: '{result}' ({len(result)} chars)" ) except Exception as exc: if "whisper" in str(exc).lower() or "No module" in str(exc): pytest.skip(f"Whisper model not available: {exc}") raise # ═══════════════════════════════════════════════════════════════════════════ # 6. Voice Cloning Tests # ═══════════════════════════════════════════════════════════════════════════ class TestVoiceCloning: """Verify voice embedding extraction, consistency, and storage.""" def test_voice_embedding_extraction(self, voice_cloner: VoiceCloner) -> None: """Feed reference audio, verify embedding is returned as non-zero numpy array.""" audio = _generate_sine(freq=200, duration_s=1.0, sample_rate=16000, amplitude=0.4) voice_id = voice_cloner.clone_voice(audio, sample_rate=16000) embedding = voice_cloner.get_voice_embedding(voice_id) assert isinstance(embedding, np.ndarray), f"Expected ndarray, got {type(embedding)}" assert embedding.size > 0, "Embedding is empty" assert float(np.linalg.norm(embedding)) > 0, "Embedding is all zeros" def test_voice_embedding_consistency(self, voice_cloner: VoiceCloner) -> None: """Same audio fed twice should produce similar embeddings (cosine similarity > 0.9).""" audio = _generate_sine(freq=200, duration_s=1.0, sample_rate=16000, amplitude=0.4) vid1 = voice_cloner.clone_voice(audio, sample_rate=16000, voice_id="test_a") vid2 = voice_cloner.clone_voice(audio, sample_rate=16000, voice_id="test_b") emb1 = voice_cloner.get_voice_embedding(vid1) emb2 = voice_cloner.get_voice_embedding(vid2) # Cosine similarity dot = float(np.dot(emb1, emb2)) norm1 = float(np.linalg.norm(emb1)) norm2 = float(np.linalg.norm(emb2)) if norm1 > 0 and norm2 > 0: cosine_sim = dot / (norm1 * norm2) else: cosine_sim = 0.0 assert cosine_sim > 0.9, ( f"Embedding inconsistency: cosine similarity = {cosine_sim:.4f} (need > 0.9)" ) def test_voice_id_storage(self, voice_cloner: VoiceCloner) -> None: """Clone a voice, verify it can be retrieved by ID.""" audio = _generate_sine(freq=180, duration_s=0.5, sample_rate=16000, amplitude=0.3) voice_id = voice_cloner.clone_voice( audio, sample_rate=16000, voice_id="eve_primary" ) assert voice_id == "eve_primary" assert voice_cloner.has_voice("eve_primary") embedding = voice_cloner.get_voice_embedding("eve_primary") assert embedding is not None assert embedding.size > 0 # Verify it appears in the voice list voices = voice_cloner.list_voices() voice_ids = [v["voice_id"] for v in voices] assert "eve_primary" in voice_ids def test_voice_embedding_different_audio(self, voice_cloner: VoiceCloner) -> None: """Different audio should produce different embeddings.""" audio_a = _generate_sine(freq=150, duration_s=1.0, sample_rate=16000, amplitude=0.4) audio_b = _generate_sine(freq=400, duration_s=1.0, sample_rate=16000, amplitude=0.4) vid_a = voice_cloner.clone_voice(audio_a, sample_rate=16000, voice_id="voice_low") vid_b = voice_cloner.clone_voice(audio_b, sample_rate=16000, voice_id="voice_high") emb_a = voice_cloner.get_voice_embedding(vid_a) emb_b = voice_cloner.get_voice_embedding(vid_b) dot = float(np.dot(emb_a, emb_b)) norm_a = float(np.linalg.norm(emb_a)) norm_b = float(np.linalg.norm(emb_b)) if norm_a > 0 and norm_b > 0: cosine_sim = dot / (norm_a * norm_b) else: cosine_sim = 1.0 # Different frequencies should yield noticeably different embeddings assert cosine_sim < 0.99, ( f"Different audio produced near-identical embeddings: cosine = {cosine_sim:.4f}" ) # ═══════════════════════════════════════════════════════════════════════════ # 7. Audio-Visual Sync Tests # ═══════════════════════════════════════════════════════════════════════════ class TestAudioVisualSync: """Verify AudioChunk timing and streaming latency.""" @pytest.mark.asyncio async def test_audio_chunk_timing(self, tts: TTSEngine) -> None: """Verify AudioChunk.duration_ms matches actual sample count / sample_rate.""" chunks = await _collect_chunks(tts, "Testing chunk timing accuracy") for i, chunk in enumerate(chunks): actual_duration_ms = len(chunk.data) / chunk.sample_rate * 1000.0 tolerance = 0.5 # allow 0.5ms rounding error assert abs(chunk.duration_ms - actual_duration_ms) < tolerance, ( f"Chunk {i}: reported duration {chunk.duration_ms:.2f}ms " f"!= actual {actual_duration_ms:.2f}ms" ) @pytest.mark.asyncio async def test_streaming_latency(self, tts: TTSEngine) -> None: """Measure time from TextChunk input to first AudioChunk output. Verify < 500ms for the sine-wave fallback TTS. """ text = "Hello, how are you?" start = time.perf_counter() first_chunk = None async for chunk in tts.synthesize(text): first_chunk = chunk break elapsed_ms = (time.perf_counter() - start) * 1000.0 assert first_chunk is not None, "No audio chunks produced" assert elapsed_ms < 500.0, ( f"First chunk latency too high: {elapsed_ms:.1f}ms (need < 500ms)" ) @pytest.mark.asyncio async def test_streaming_produces_final_flag(self, tts: TTSEngine) -> None: """Verify the last AudioChunk in a synthesis has is_final=True.""" chunks = await _collect_chunks(tts, "Final chunk test") assert len(chunks) > 0, "No chunks produced" assert chunks[-1].is_final is True, "Last chunk missing is_final=True flag" # All non-last chunks should not be final for chunk in chunks[:-1]: assert chunk.is_final is False, "Non-last chunk incorrectly marked is_final" @pytest.mark.asyncio async def test_synthesize_stream_from_text_chunks(self, tts: TTSEngine) -> None: """Verify synthesize_stream consumes TextChunks and yields AudioChunks.""" async def _text_source() -> AsyncIterator[TextChunk]: yield TextChunk(text="Hello world.", is_sentence_end=True) yield TextChunk(text="How are you?", is_sentence_end=True) audio_chunks: List[AudioChunk] = [] async for audio_chunk in tts.synthesize_stream(_text_source()): audio_chunks.append(audio_chunk) assert len(audio_chunks) > 0, "synthesize_stream produced no audio" total_audio = _concat_chunks(audio_chunks) assert total_audio.size > 0, "synthesize_stream audio is empty" assert _rms(total_audio) > 0.001, "synthesize_stream audio is silent"