refactor: formaliza formatos de áudio e alinhamento PCM
This commit is contained in:
@@ -223,3 +223,35 @@ test("late PCM after stop is ignored", async () => {
|
||||
assert.equal(player.playing, false);
|
||||
assert.equal(player.sources.size, 0);
|
||||
});
|
||||
|
||||
|
||||
test("uses the output sample rate declared by the server", async () => {
|
||||
const context = new FakeAudioContext();
|
||||
const player = new PCMPlayer({ audioContext: context });
|
||||
player.configure({ encoding: "pcm_s16le", sample_rate: 24000, channels: 1 });
|
||||
await player.enqueue(pcm([100, 200, 300, 400]));
|
||||
assert.equal(player.sampleRate, 24000);
|
||||
assert.equal(context.sources[0].buffer.duration, 4 / 24000);
|
||||
});
|
||||
|
||||
for (const format of [
|
||||
undefined,
|
||||
{ encoding: "wav", sample_rate: 48000, channels: 1 },
|
||||
{ encoding: "pcm_s16le", sample_rate: 48000, channels: 2 },
|
||||
{ encoding: "pcm_s16le", sample_rate: "48000", channels: 1 },
|
||||
{ encoding: "pcm_s16le", sample_rate: 0, channels: 1 },
|
||||
{ encoding: "pcm_s16le", sample_rate: 192000, channels: 1 },
|
||||
]) {
|
||||
test(`rejects unsupported playback metadata: ${JSON.stringify(format)}`, () => {
|
||||
const player = new PCMPlayer({ audioContext: new FakeAudioContext() });
|
||||
assert.throws(() => player.configure(format), /Formato de áudio/);
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects format changes while audio is scheduled", async () => {
|
||||
const player = new PCMPlayer({ audioContext: new FakeAudioContext() });
|
||||
await player.enqueue(pcm([100, 200]));
|
||||
assert.throws(() => player.configure({
|
||||
encoding: "pcm_s16le", sample_rate: 24000, channels: 1,
|
||||
}), /durante a reprodução/);
|
||||
});
|
||||
|
||||
@@ -733,3 +733,14 @@ test("barge-in resets player before new TTS response", () => {
|
||||
assert.equal(finished, false);
|
||||
assert.equal(resetCount, 1);
|
||||
});
|
||||
|
||||
|
||||
test("session.ready configures output before starting microphone capture", () => {
|
||||
const { controller } = makeController();
|
||||
const operations = [];
|
||||
const format = { encoding: "pcm_s16le", sample_rate: 24000, channels: 1 };
|
||||
controller.player = { configure: (received) => operations.push(received) };
|
||||
controller.startListening = () => operations.push("listen");
|
||||
controller.handleGatewayEvent({ type: "session.ready", output_audio: format });
|
||||
assert.deepEqual(operations, [format, "listen"]);
|
||||
});
|
||||
|
||||
175
tests/test_audio_contract.py
Normal file
175
tests/test_audio_contract.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
@@ -1865,9 +1864,9 @@ def test_websocket_streaming_agent_and_tts():
|
||||
yield "Olá, como posso ajudar?"
|
||||
yield " Estou à disposição."
|
||||
|
||||
class StreamingTestTTS(TTSAdapter):
|
||||
class StreamingTestTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
yield text.encode("utf-8")
|
||||
yield text.encode("utf-16-le")
|
||||
|
||||
with patch.object(
|
||||
main,
|
||||
@@ -1967,7 +1966,7 @@ def test_websocket_interrupt_during_streaming_tts():
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
yield "Olá, esta é uma resposta longa."
|
||||
|
||||
class SlowStreamingTTS(TTSAdapter):
|
||||
class SlowStreamingTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
tts_started.set()
|
||||
@@ -2106,7 +2105,7 @@ def test_websocket_session_end_during_streaming_tts():
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
yield "Esta é uma resposta longa."
|
||||
|
||||
class SlowStreamingTTS(TTSAdapter):
|
||||
class SlowStreamingTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"\x01\x02\x03\x04"
|
||||
@@ -2303,7 +2302,7 @@ def test_websocket_interrupt_wins_pending_tts_completion():
|
||||
async def stream(self, session_id, turn_id, text):
|
||||
yield "Esta resposta será interrompida."
|
||||
|
||||
class ControlledTTS(TTSAdapter):
|
||||
class ControlledTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"\x01\x02\x03\x04"
|
||||
|
||||
@@ -18,7 +18,7 @@ Non-responsibilities:
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from voice_assistant.audio.models import CANONICAL_AUDIO_FORMAT
|
||||
from voice_assistant.audio.models import AudioFormat, CANONICAL_AUDIO_FORMAT
|
||||
from voice_assistant.audio_gateway.app.protocol import (
|
||||
AgentDelta,
|
||||
AgentResponse,
|
||||
@@ -83,6 +83,7 @@ def test_session_ready_output():
|
||||
protocol_version="1.0",
|
||||
session_id="session-1",
|
||||
audio=CANONICAL_AUDIO_FORMAT,
|
||||
output_audio=AudioFormat(encoding="pcm_s16le", sample_rate=48000, channels=1),
|
||||
)
|
||||
|
||||
assert event.model_dump() == {
|
||||
@@ -94,6 +95,11 @@ def test_session_ready_output():
|
||||
"sample_rate": 16000,
|
||||
"channels": 1,
|
||||
},
|
||||
"output_audio": {
|
||||
"encoding": "pcm_s16le",
|
||||
"sample_rate": 48000,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from voice_assistant.application.events import SpeechChunk
|
||||
from voice_assistant.application.voice_session import VoiceSession
|
||||
from voice_assistant.domain.session import SessionState
|
||||
from voice_assistant.stt.mock import MockSTTAdapter
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
from voice_assistant.tts.mock import MockTTSAdapter
|
||||
from voice_assistant.turn_manager import TurnManager
|
||||
from voice_assistant.voice_pipeline import VoicePipeline
|
||||
@@ -93,17 +92,17 @@ async def test_streaming_cancellation_releases_stream():
|
||||
async def test_stopping_synthesis_early_closes_provider_generator():
|
||||
closed = asyncio.Event()
|
||||
|
||||
class TTS(TTSAdapter):
|
||||
class TTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"audio"
|
||||
yield b"audi"
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
pipeline = make_pipeline(MockAgentAdapter(), TTS())
|
||||
async with aclosing(pipeline.synthesize_response("Olá")) as stream:
|
||||
assert await anext(stream) == b"audio"
|
||||
assert await anext(stream) == b"audi"
|
||||
assert closed.is_set()
|
||||
|
||||
|
||||
@@ -113,10 +112,10 @@ async def test_interrupt_during_delivery_closes_nonstreaming_tts():
|
||||
synthesis_closed = asyncio.Event()
|
||||
stt = MockSTTAdapter()
|
||||
|
||||
class TTS(TTSAdapter):
|
||||
class TTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
try:
|
||||
yield b"audio"
|
||||
yield b"audi"
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
synthesis_closed.set()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.tts.adapter import TTSAdapter
|
||||
"""
|
||||
Tests for the Voice Pipeline lifecycle orchestration.
|
||||
|
||||
@@ -850,9 +849,9 @@ async def test_stream_turn_response_produces_audio_before_agent_finishes():
|
||||
yield " Estou à disposição."
|
||||
self.finished = True
|
||||
|
||||
class StreamingTTS(TTSAdapter):
|
||||
class StreamingTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
yield text.encode("utf-8")
|
||||
yield text.encode("utf-16-le")
|
||||
|
||||
agent = StreamingAgent()
|
||||
|
||||
@@ -938,9 +937,9 @@ async def test_stream_turn_response_propagates_agent_failure():
|
||||
yield "Olá, como posso ajudar?"
|
||||
raise RuntimeError("Simulated OCI streaming failure.")
|
||||
|
||||
class StreamingTTS(TTSAdapter):
|
||||
class StreamingTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
yield text.encode("utf-8")
|
||||
yield text.encode("utf-16-le")
|
||||
|
||||
pipeline = VoicePipeline(
|
||||
session=SessionState(session_id="agent-failure-session"),
|
||||
@@ -984,7 +983,7 @@ async def test_stream_turn_response_cancels_agent_on_tts_failure():
|
||||
finally:
|
||||
self.cancelled.set()
|
||||
|
||||
class FailingTTS(TTSAdapter):
|
||||
class FailingTTS(MockTTSAdapter):
|
||||
async def synthesize(self, text):
|
||||
raise RuntimeError("Simulated TTS failure.")
|
||||
yield b"" # Keep this method an async generator.
|
||||
@@ -1036,7 +1035,7 @@ async def test_stream_turn_response_external_cancellation():
|
||||
finally:
|
||||
self.closed.set()
|
||||
|
||||
class BlockingTTS(TTSAdapter):
|
||||
class BlockingTTS(MockTTSAdapter):
|
||||
def __init__(self):
|
||||
self.started = asyncio.Event()
|
||||
self.closed = asyncio.Event()
|
||||
@@ -1045,7 +1044,7 @@ async def test_stream_turn_response_external_cancellation():
|
||||
try:
|
||||
self.started.set()
|
||||
await asyncio.Event().wait()
|
||||
yield b"audio"
|
||||
yield b"audi"
|
||||
finally:
|
||||
self.closed.set()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class MockHTTPStream:
|
||||
status_code: int = 200,
|
||||
) -> None:
|
||||
self.chunks = chunks
|
||||
self.headers = {"content-type": "audio/pcm"}
|
||||
self.status_code = status_code
|
||||
|
||||
async def __aenter__(self):
|
||||
|
||||
Reference in New Issue
Block a user