feat: migra cliente web para áudio contínuo com VAD no backend
This commit is contained in:
223
tests/test_continuous_gateway.py
Normal file
223
tests/test_continuous_gateway.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Continuous PCM WebSocket mode delegates turn decisions to backend VAD."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from voice_assistant.agent.mock import MockAgentAdapter
|
||||
from voice_assistant.audio_gateway.app import main
|
||||
from voice_assistant.stt.mock import MockSTTAdapter
|
||||
from voice_assistant.tts.mock import MockTTSAdapter
|
||||
from voice_assistant.vad.adapter import VADAdapter
|
||||
from voice_assistant.vad.models import SpeechStarted, SpeechEnded
|
||||
|
||||
|
||||
class VAD(VADAdapter):
|
||||
def __init__(self):
|
||||
self.script = []
|
||||
self.tail = ()
|
||||
self.received = []
|
||||
self.closed = False
|
||||
|
||||
async def process(self, audio):
|
||||
self.received.append(audio)
|
||||
return self.script.pop(0) if self.script else ()
|
||||
|
||||
async def flush(self):
|
||||
return self.tail
|
||||
|
||||
async def reset(self):
|
||||
pass
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
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 stop_session(self):
|
||||
await self.emit_final('Pergunta de teste')
|
||||
await super().stop_session()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def components(monkeypatch):
|
||||
detector = VAD()
|
||||
providers = []
|
||||
|
||||
def create_stt(**kwargs):
|
||||
stt = STT()
|
||||
providers.append(stt)
|
||||
return stt
|
||||
|
||||
monkeypatch.setattr(main, 'create_vad_adapter', lambda **kwargs: detector)
|
||||
monkeypatch.setattr(main, 'create_stt_adapter', create_stt)
|
||||
monkeypatch.setattr(main, 'create_agent_adapter', lambda **kwargs: MockAgentAdapter())
|
||||
monkeypatch.setattr(main, 'create_tts_adapter', lambda **kwargs: MockTTSAdapter())
|
||||
return detector, providers
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect(mode='server'):
|
||||
with TestClient(main.app).websocket_connect('/ws/audio') as socket:
|
||||
socket.send_json({'type': 'session.start', 'turn_detection': mode})
|
||||
ready = socket.receive_json()
|
||||
assert ready['type'] == 'session.ready'
|
||||
yield socket, ready
|
||||
|
||||
|
||||
def send_audio(socket, sequence, audio):
|
||||
socket.send_json({'type': 'audio.input', 'sequence_number': sequence})
|
||||
socket.send_bytes(audio)
|
||||
|
||||
|
||||
def read_turn(socket):
|
||||
events, audio = [], []
|
||||
for _ in range(20):
|
||||
message = socket.receive()
|
||||
if message.get('bytes') is not None:
|
||||
audio.append(message['bytes'])
|
||||
else:
|
||||
import json
|
||||
event = json.loads(message['text'])
|
||||
assert event['type'] != 'error', event
|
||||
events.append(event)
|
||||
if event['type'] == 'turn.ended':
|
||||
return events, audio
|
||||
raise AssertionError('Turn did not complete within expected events')
|
||||
|
||||
|
||||
def test_continuous_mode_receives_audio_before_first_turn(components):
|
||||
detector, providers = components
|
||||
with connect() as (socket, ready):
|
||||
assert ready['turn_detection'] == 'server'
|
||||
assert ready['max_audio_chunk_bytes'] == 65536
|
||||
send_audio(socket, 0, bytes(512))
|
||||
socket.send_json({'type': 'session.end'})
|
||||
assert socket.receive_json()['type'] == 'session.ended'
|
||||
assert detector.received == [bytes(512)]
|
||||
assert providers == []
|
||||
assert detector.closed
|
||||
|
||||
|
||||
def test_vad_opens_and_closes_turn_and_input_sequence_survives_next_turn(components):
|
||||
detector, providers = components
|
||||
detector.script = [
|
||||
(SpeechStarted(0),), (SpeechEnded(400, 'silence'),),
|
||||
(), (SpeechStarted(900),),
|
||||
]
|
||||
detector.tail = (SpeechEnded(1024, 'end_of_input'),)
|
||||
with connect() as (socket, ready):
|
||||
send_audio(socket, 0, bytes(512))
|
||||
assert socket.receive_json()['type'] == 'turn.started'
|
||||
send_audio(socket, 1, bytes(512))
|
||||
events, audio = read_turn(socket)
|
||||
assert events[-1]['turn_id'] == 1
|
||||
assert any(event['type'] == 'stt.final' for event in events)
|
||||
assert b''.join(audio) == b'\x01\x02\x03\x04\x05\x06\x07\x08'
|
||||
send_audio(socket, 2, bytes(512))
|
||||
send_audio(socket, 3, bytes(512))
|
||||
assert socket.receive_json()['turn_id'] == 2
|
||||
socket.send_json({'type': 'audio.input.end'})
|
||||
events, audio = read_turn(socket)
|
||||
assert events[-1]['turn_id'] == 2
|
||||
assert audio
|
||||
assert len(providers) == 2
|
||||
assert len(detector.received) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sequence', [-1, True, 0.5, '0', 1, 2])
|
||||
def test_bad_sequence_is_rejected_without_consuming_input(components, sequence):
|
||||
detector, _ = components
|
||||
with connect() as (socket, ready):
|
||||
socket.send_json({'type': 'audio.input', 'sequence_number': sequence})
|
||||
error = socket.receive_json()
|
||||
assert error['code'] == 'AUDIO_SEQUENCE_ERROR'
|
||||
assert error['details']['expected_sequence_number'] == 0
|
||||
send_audio(socket, 0, bytes(512))
|
||||
socket.send_json({'type': 'session.end'})
|
||||
assert socket.receive_json()['type'] == 'session.ended'
|
||||
assert len(detector.received) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('audio', 'code'), [
|
||||
(b'', 'AUDIO_SEQUENCE_ERROR'), (bytes(3), 'AUDIO_SEQUENCE_ERROR'),
|
||||
(bytes(6), 'MESSAGE_TOO_LARGE'),
|
||||
])
|
||||
def test_bad_pcm_does_not_advance_global_sequence(components, monkeypatch, audio, code):
|
||||
detector, _ = components
|
||||
config = main.app_config.model_copy(update={'voice_input': {'max_input_chunk_bytes': 4}})
|
||||
monkeypatch.setattr(main, 'app_config', config)
|
||||
with connect() as (socket, ready):
|
||||
assert ready['max_audio_chunk_bytes'] == 4
|
||||
send_audio(socket, 0, audio)
|
||||
assert socket.receive_json()['code'] == code
|
||||
send_audio(socket, 0, bytes(4))
|
||||
socket.send_json({'type': 'session.end'})
|
||||
assert socket.receive_json()['type'] == 'session.ended'
|
||||
assert detector.received == [bytes(4)]
|
||||
|
||||
|
||||
def test_duplicate_metadata_does_not_overwrite_pending_pcm(components):
|
||||
detector, _ = components
|
||||
with connect() as (socket, ready):
|
||||
socket.send_json({'type': 'audio.input', 'sequence_number': 0})
|
||||
socket.send_json({'type': 'audio.input', 'sequence_number': 1})
|
||||
assert socket.receive_json()['code'] == 'AUDIO_SEQUENCE_ERROR'
|
||||
socket.send_bytes(bytes(512))
|
||||
socket.send_json({'type': 'session.end'})
|
||||
assert socket.receive_json()['type'] == 'session.ended'
|
||||
assert len(detector.received) == 1
|
||||
|
||||
|
||||
def test_binary_frame_requires_metadata(components):
|
||||
with connect() as (socket, ready):
|
||||
socket.send_bytes(bytes(512))
|
||||
assert socket.receive_json()['code'] == 'INVALID_AUDIO_FRAME'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('event', [
|
||||
{'type': 'audio.start'}, {'type': 'audio.stop', 'turn_id': 1},
|
||||
{'type': 'audio.chunk', 'turn_id': 1, 'sequence_number': 0},
|
||||
])
|
||||
def test_client_turn_commands_are_rejected_in_server_mode(components, event):
|
||||
with connect() as (socket, ready):
|
||||
socket.send_json(event)
|
||||
assert socket.receive_json()['code'] == 'TURN_CONTROLLED_BY_VAD'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('event', [
|
||||
{'type': 'audio.input', 'sequence_number': 0}, {'type': 'audio.input.end'},
|
||||
])
|
||||
def test_manual_mode_rejects_continuous_input(components, event):
|
||||
with connect('client') as (socket, ready):
|
||||
assert ready['turn_detection'] == 'client'
|
||||
socket.send_json(event)
|
||||
assert socket.receive_json()['code'] == 'INVALID_INPUT_MODE'
|
||||
|
||||
|
||||
def test_input_end_cannot_overtake_pending_binary_frame(components):
|
||||
with connect() as (socket, ready):
|
||||
socket.send_json({'type': 'audio.input', 'sequence_number': 0})
|
||||
socket.send_json({'type': 'audio.input.end'})
|
||||
assert socket.receive_json()['code'] == 'INVALID_MESSAGE'
|
||||
|
||||
|
||||
def test_initialization_failure_is_reported_and_can_be_followed_by_manual_session(components, monkeypatch):
|
||||
def unavailable(**kwargs):
|
||||
raise ValueError('VAD unavailable')
|
||||
|
||||
monkeypatch.setattr(main, 'create_vad_adapter', unavailable)
|
||||
with TestClient(main.app).websocket_connect('/ws/audio') as socket:
|
||||
socket.send_json({'type': 'session.start', 'turn_detection': 'server'})
|
||||
assert socket.receive_json()['code'] == 'SESSION_START_FAILED'
|
||||
socket.send_json({'type': 'session.start'})
|
||||
assert socket.receive_json()['type'] == 'session.ready'
|
||||
Reference in New Issue
Block a user