primeira versão customizada de VAD- Voice Activity Detection
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user