"""PCM framing, per-direction metadata, and HTTP fragmentation guarantees.""" from contextlib import aclosing import httpx import pytest from fastapi.testclient import TestClient from pydantic import ValidationError from voice_assistant.agent.mock import MockAgentAdapter from voice_assistant.application.voice_session import VoiceSession from voice_assistant.audio.models import AudioFormat, CANONICAL_AUDIO_FORMAT from voice_assistant.audio_gateway.app import main from voice_assistant.domain.session import SessionState from voice_assistant.stt.mock import MockSTTAdapter from voice_assistant.tts.mock import MockTTSAdapter from voice_assistant.tts.voxcpm import VoxCPMTTSAdapter from voice_assistant.turn_manager import TurnManager from voice_assistant.voice_pipeline import VoicePipeline @pytest.fixture def anyio_backend(): return "asyncio" @pytest.mark.parametrize("field", ["sample_rate", "channels"]) @pytest.mark.parametrize("value", [0, -1, True, "16000", 1.5]) def test_audio_format_requires_positive_integer_dimensions(field, value): fields = {"encoding": "pcm_s16le", "sample_rate": 16000, "channels": 1} fields[field] = value with pytest.raises(ValidationError): AudioFormat(**fields) def test_shared_format_cannot_be_mutated(): with pytest.raises(ValidationError): CANONICAL_AUDIO_FORMAT.sample_rate = 48000 assert CANONICAL_AUDIO_FORMAT.sample_rate == 16000 @pytest.mark.parametrize("channels", [1, 2]) @pytest.mark.parametrize("size", [0, 1, 2, 3, 4, 5, 8]) def test_pcm_validation_requires_complete_channel_frames(channels, size): format_ = AudioFormat(encoding="pcm_s16le", sample_rate=16000, channels=channels) if not size or size % (2 * channels): with pytest.raises(ValueError): format_.validate_chunk(bytes(size)) else: format_.validate_chunk(bytes(size)) def make_pipeline(tts): return VoicePipeline( session=SessionState(), turn_manager=TurnManager(), agent=MockAgentAdapter(), tts=tts, ) @pytest.mark.anyio async def test_invalid_input_does_not_advance_sequence_or_reach_stt(): class STT(MockSTTAdapter): def __init__(self): super().__init__() self.received = [] async def send_audio(self, audio): await super().send_audio(audio) self.received.append(audio) async def emit(event): pass stt = STT() session = VoiceSession( pipeline=make_pipeline(MockTTSAdapter()), create_stt=lambda: stt, emit=emit, ) try: turn_id = await session.start_turn() with pytest.raises(ValueError, match="complete frames"): await session.send_audio(turn_id, 0, b"\x00") assert session.expected_audio_sequence == 0 assert stt.received == [] await session.send_audio(turn_id, 0, b"\x00\x00") assert session.expected_audio_sequence == 1 assert stt.received == [b"\x00\x00"] finally: await session.aclose() @pytest.mark.anyio async def test_pipeline_rejects_misaligned_tts_output(): class TTS(MockTTSAdapter): async def synthesize(self, text): yield b"\x00" async with aclosing(make_pipeline(TTS()).synthesize_response("Olá")) as stream: with pytest.raises(ValueError, match="complete frames"): await anext(stream) class FragmentedStream(httpx.AsyncByteStream): def __init__(self, chunks): self.chunks = chunks async def __aiter__(self): for chunk in self.chunks: yield chunk def mock_tts_http(monkeypatch, chunks, content_type="audio/pcm"): original_client = httpx.AsyncClient def handle(request): return httpx.Response( 200, headers={"content-type": content_type}, stream=FragmentedStream(chunks), ) monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: original_client(transport=httpx.MockTransport(handle), **kwargs), ) @pytest.mark.anyio @pytest.mark.parametrize("chunks", [ [b"\x01", b"\x02\x03", b"", b"\x04"], [b"\x01\x02\x03", b"\x04"], [b"\x01\x02\x03\x04"], ]) async def test_voxcpm_reassembles_http_fragments_without_losing_bytes(monkeypatch, chunks): mock_tts_http(monkeypatch, chunks) tts = VoxCPMTTSAdapter(server="http://test", model="test", voice="default") output = [chunk async for chunk in tts.synthesize("Olá")] assert b"".join(output) == b"\x01\x02\x03\x04" assert all(chunk and len(chunk) % 2 == 0 for chunk in output) @pytest.mark.anyio async def test_voxcpm_rejects_truncated_final_frame(monkeypatch): mock_tts_http(monkeypatch, [b"\x01\x02\x03"]) tts = VoxCPMTTSAdapter(server="http://test", model="test", voice="default") with pytest.raises(ValueError, match="incomplete PCM frame"): _ = [chunk async for chunk in tts.synthesize("Olá")] @pytest.mark.anyio @pytest.mark.parametrize("content_type", ["audio/wav", "text/event-stream", "application/json"]) async def test_voxcpm_rejects_non_pcm_response(monkeypatch, content_type): mock_tts_http(monkeypatch, [b"data"], content_type) tts = VoxCPMTTSAdapter(server="http://test", model="test", voice="default") with pytest.raises(ValueError, match="raw PCM"): _ = [chunk async for chunk in tts.synthesize("Olá")] @pytest.mark.parametrize("options", [{"response_format": "wav"}, {"stream_format": "sse"}]) def test_voxcpm_rejects_incompatible_transport_settings(options): with pytest.raises(ValueError): VoxCPMTTSAdapter(server="http://test", model="test", voice="default", **options) def test_gateway_announces_provider_output_format_and_preserves_input(monkeypatch): class TTS(MockTTSAdapter): output_format = AudioFormat(encoding="pcm_s16le", sample_rate=24000, channels=1) monkeypatch.setattr(main, "create_tts_adapter", lambda **kwargs: TTS()) monkeypatch.setattr(main, "create_agent_adapter", lambda **kwargs: MockAgentAdapter()) with TestClient(main.app).websocket_connect("/ws/audio") as socket: socket.send_json({"type": "session.start"}) ready = socket.receive_json() assert ready["audio"]["sample_rate"] == 16000 assert ready["output_audio"] == { "encoding": "pcm_s16le", "sample_rate": 24000, "channels": 1, }