primeira versão customizada de VAD- Voice Activity Detection

This commit is contained in:
2026-10-30 19:15:01 +00:00
parent 2449b34137
commit aba05cb826
14 changed files with 3457 additions and 129 deletions

View File

@@ -348,6 +348,142 @@ def test_websocket_session_end():
assert session_ended["session_id"] == session_id
def test_websocket_session_end_closes_agent_provider():
"""
Explicit session.end must close the Agent provider.
"""
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "session.end",
})
ended = websocket.receive_json()
assert ended["type"] == "session.ended"
agent.aclose.assert_awaited_once()
def test_websocket_disconnect_closes_agent_provider():
"""
Unexpected WebSocket disconnect must close the Agent provider.
"""
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
# Leave without sending session.end.
# TestClient closes the WebSocket context.
agent.aclose.assert_awaited_once()
def test_websocket_interrupt_preserves_agent_provider():
"""
Interrupt must cancel the turn without closing the Agent provider.
The provider is closed only when the WebSocket session ends.
"""
created_stt.clear()
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "audio.start",
})
started = websocket.receive_json()
assert started["type"] == "turn.started"
websocket.send_json({
"type": "interrupt",
"turn_id": started["turn_id"],
})
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
# Interrupt must not close the provider.
agent.aclose.assert_not_awaited()
# The same WebSocket must accept a new turn.
websocket.send_json({
"type": "audio.start",
})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != started["turn_id"]
# Provider must remain open.
agent.aclose.assert_not_awaited()
websocket.send_json({
"type": "session.end",
})
ended = websocket.receive_json()
assert ended["type"] == "session.ended"
agent.aclose.assert_awaited_once()
def test_websocket_forwards_stt_events():
created_stt.clear()
created_turn_managers.clear()
@@ -1800,3 +1936,477 @@ def test_websocket_streaming_agent_and_tts():
turn_ended = websocket.receive_json()
assert turn_ended["type"] == "turn.ended"
def test_websocket_interrupt_during_streaming_tts():
"""
Verify that interrupt cancels an active streaming TTS turn
while normal turn finalization is pending.
"""
created_stt.clear()
tts_started = threading.Event()
tts_cancelled = threading.Event()
class SlowStreamingAgent:
async def stream(self, session_id, turn_id, text):
yield "Olá, esta é uma resposta longa."
class SlowStreamingTTS:
async def synthesize(self, text):
try:
tts_started.set()
yield b"\x01\x02\x03\x04"
# Keep synthesis active until cancellation.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=SlowStreamingAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=SlowStreamingTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Wait until the Gateway delivers the first TTS audio chunk.
first_audio_received = False
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
first_audio_received = True
break
import json
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
assert first_audio_received
assert tts_started.is_set()
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
# Streaming events may precede interruption.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
import json
event = json.loads(message["text"])
if event["type"] == "interrupted":
assert event["turn_id"] == turn_id
break
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
else:
raise AssertionError(
"Gateway did not confirm interruption."
)
assert tts_cancelled.wait(timeout=2.0)
# A new turn must be accepted in the same session.
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != turn_id
websocket.send_json({
"type": "interrupt",
"turn_id": next_turn["turn_id"],
})
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
def test_websocket_session_end_during_streaming_tts():
"""
Verify session.end cancels an active streaming TTS turn.
The Gateway must cancel provider execution before
acknowledging session closure.
"""
import json
created_stt.clear()
tts_cancelled = threading.Event()
class SlowStreamingAgent:
async def stream(self, session_id, turn_id, text):
yield "Esta é uma resposta longa."
class SlowStreamingTTS:
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"
# Keep synthesis running until cancelled.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=SlowStreamingAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=SlowStreamingTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Wait until TTS audio reaches the client.
first_audio_received = False
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
first_audio_received = True
break
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
assert first_audio_received
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "session.end",
})
# Streaming events may already be queued.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
event = json.loads(message["text"])
if event["type"] == "session.ended":
assert event["session_id"] == ready["session_id"]
break
if event["type"] == "turn.ended":
raise AssertionError(
"Turn completed normally during session shutdown."
)
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
else:
raise AssertionError(
"Gateway did not confirm session closure."
)
assert tts_cancelled.wait(timeout=2.0)
def test_websocket_interrupt_after_turn_completed():
"""
Verify behavior when interrupt arrives after turn.ended.
A completed turn must not be cancelled again.
"""
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Consume events until the first turn finishes.
for _ in range(30):
message = websocket.receive()
if message.get("bytes") is not None:
continue
import json
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
if event["type"] == "stt.final":
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
if event["type"] == "turn.ended":
break
else:
raise AssertionError(
"Turn did not complete normally."
)
# Interrupt the already completed turn.
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
response = websocket.receive_json()
# Existing Gateway behavior may acknowledge stale IDs.
# Capture the current contract before changing it.
assert response["type"] == "error"
assert response["code"] == "INVALID_TURN"
assert response["turn_id"] == turn_id
def test_websocket_interrupt_wins_pending_tts_completion():
"""
Verify interruption wins while TTS completion is pending.
The interrupted turn must not emit turn.ended,
and a new turn must be accepted.
"""
import json
created_stt.clear()
tts_cancelled = threading.Event()
class ControlledAgent:
async def stream(self, session_id, turn_id, text):
yield "Esta resposta será interrompida."
class ControlledTTS:
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"
# Completion remains pending until cancellation.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=ControlledAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=ControlledTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "audio.start",
})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Observe the first PCM chunk.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
break
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(event)
else:
raise AssertionError(
"TTS did not produce its first chunk."
)
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
terminal_events = []
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
event = json.loads(message["text"])
if event["type"] in {
"turn.ended",
"interrupted",
}:
terminal_events.append(event["type"])
if event["type"] == "error":
raise AssertionError(event)
if event["type"] == "interrupted":
break
else:
raise AssertionError(
"Interruption was not acknowledged."
)
assert terminal_events == ["interrupted"]
assert tts_cancelled.wait(timeout=2.0)
# Confirm that the session accepts another turn.
websocket.send_json({
"type": "audio.start",
})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != turn_id
websocket.send_json({
"type": "interrupt",
"turn_id": next_turn["turn_id"],
})
response = websocket.receive_json()
assert response["type"] == "interrupted"
assert response["turn_id"] == next_turn["turn_id"]