fix(workflow): track fixed speech at transport output
This commit is contained in:
@@ -3,8 +3,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
|
||||
from services.pipecat.call_lifecycle import CallEndCoordinator
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
InterruptionFrame,
|
||||
)
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
playback_marker_for,
|
||||
)
|
||||
|
||||
|
||||
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -65,17 +72,68 @@ class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
first_marker = playback_marker_for(first_completion)
|
||||
second_marker = playback_marker_for(second_completion)
|
||||
self.assertIsNotNone(first_marker)
|
||||
self.assertIsNotNone(second_marker)
|
||||
|
||||
await first_marker.completion.mark_played()
|
||||
self.assertTrue(first_completion.done())
|
||||
self.assertFalse(second_completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
await second_marker.completion.mark_played()
|
||||
self.assertTrue(second_completion.done())
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_previous_speech_stop_does_not_complete_fixed_speech(self):
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
await marker.completion.mark_played()
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_delayed_previous_speech_boundary_cannot_claim_marker(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
self.coordinator.begin("workflow_completed")
|
||||
await self.coordinator.arm_after_tracked_speech()
|
||||
|
||||
await self.coordinator.observe(BotStartedSpeakingFrame())
|
||||
await self.coordinator.observe(BotStoppedSpeakingFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
self.assertEqual(self.reasons, [])
|
||||
await marker.completion.mark_played()
|
||||
self.assertEqual(self.reasons, ["workflow_completed"])
|
||||
|
||||
async def test_interruption_completes_marker_already_in_output_queue(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
marker.completion.mark_queued()
|
||||
|
||||
await self.coordinator.observe(InterruptionFrame())
|
||||
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
async def test_interruption_does_not_complete_marker_not_yet_queued(self):
|
||||
completion = self.coordinator.track_speech()
|
||||
marker = playback_marker_for(completion)
|
||||
|
||||
await self.coordinator.observe(InterruptionFrame())
|
||||
|
||||
self.assertFalse(completion.done())
|
||||
marker.completion.mark_queued()
|
||||
await marker.completion.mark_played()
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
146
backend/tests/test_fixed_speech_playback.py
Normal file
146
backend/tests/test_fixed_speech_playback.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from models import AssistantConfig
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
import services.brains # Initialize the brain registry before realtime imports.
|
||||
from services.fixed_speech import FixedSpeechOutput
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
FixedSpeechPlaybackMarkerFrame,
|
||||
)
|
||||
from services.pipecat.transports import build_ws_transport
|
||||
from services.runtime_variables import DynamicVariableStore
|
||||
from services.workflow.realtime import WorkflowRealtimeController
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
|
||||
|
||||
class FixedSpeechPlaybackTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_pipeline_speech_queues_marker_immediately_after_tts(self):
|
||||
queued = []
|
||||
|
||||
async def queue_end(_reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def queue_frame(frame) -> None:
|
||||
queued.append(frame)
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
output = FixedSpeechOutput(
|
||||
DynamicVariableStore({}),
|
||||
SimpleNamespace(call_end=call_end, queue_frame=queue_frame),
|
||||
)
|
||||
|
||||
completion = await output.speak(
|
||||
"固定结束语",
|
||||
source="test",
|
||||
record_history=False,
|
||||
)
|
||||
|
||||
self.assertIsInstance(queued[0], TTSSpeakFrame)
|
||||
self.assertIsInstance(queued[1], FixedSpeechPlaybackMarkerFrame)
|
||||
self.assertIs(queued[1].completion, completion)
|
||||
|
||||
async def test_websocket_output_resolves_marker(self):
|
||||
async def queue_end(_reason: str) -> None:
|
||||
pass
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
completion = call_end.track_speech()
|
||||
marker = FixedSpeechPlaybackMarkerFrame(completion=completion)
|
||||
websocket = SimpleNamespace(headers={})
|
||||
output = build_ws_transport(websocket).output()
|
||||
|
||||
await output.write_transport_frame(marker)
|
||||
|
||||
self.assertTrue(completion.done())
|
||||
|
||||
async def test_realtime_end_ignores_unrelated_speech_boundaries(self):
|
||||
graph = {
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "end",
|
||||
"type": "end",
|
||||
"data": {"message": "感谢来电,再见。", "scope": "session"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
reasons = []
|
||||
queued = []
|
||||
|
||||
async def queue_end(reason: str) -> None:
|
||||
reasons.append(reason)
|
||||
|
||||
async def queue_frame(frame) -> None:
|
||||
queued.append(frame)
|
||||
|
||||
class FakeRealtime:
|
||||
def __init__(self):
|
||||
self.provider_completion = None
|
||||
|
||||
async def update_session(self, _instructions, _tools):
|
||||
pass
|
||||
|
||||
async def speak_fixed(self, _text, *, suppress_transcript=True):
|
||||
self.provider_completion = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
return self.provider_completion
|
||||
|
||||
call_end = CallEndCoordinator(queue_end)
|
||||
realtime = FakeRealtime()
|
||||
controller = WorkflowRealtimeController(
|
||||
cfg=AssistantConfig(type="workflow", graph=graph),
|
||||
engine=WorkflowEngine(graph),
|
||||
store=DynamicVariableStore({}),
|
||||
runtime=SimpleNamespace(
|
||||
realtime=realtime,
|
||||
queue_frame=queue_frame,
|
||||
call_end=call_end,
|
||||
session_id="test-session",
|
||||
client_tools=None,
|
||||
set_input_enabled=lambda _enabled: None,
|
||||
capture_image=None,
|
||||
),
|
||||
)
|
||||
|
||||
end_task = asyncio.create_task(controller._enter_end("end"))
|
||||
while realtime.provider_completion is None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await call_end.observe(BotStartedSpeakingFrame())
|
||||
await call_end.observe(BotStoppedSpeakingFrame())
|
||||
self.assertEqual(reasons, [])
|
||||
self.assertFalse(end_task.done())
|
||||
|
||||
realtime.provider_completion.set_result(None)
|
||||
marker = None
|
||||
while marker is None:
|
||||
await asyncio.sleep(0)
|
||||
marker = next(
|
||||
(
|
||||
frame
|
||||
for frame in queued
|
||||
if isinstance(frame, FixedSpeechPlaybackMarkerFrame)
|
||||
),
|
||||
None,
|
||||
)
|
||||
await marker.completion.mark_played()
|
||||
await end_task
|
||||
|
||||
self.assertEqual(reasons, ["workflow_completed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user