176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
"""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,
|
|
}
|