feat: enhance call end coordination with state management

- Added support for tracking call end states (pending, speaking, cancelled) in CallEndCoordinator.
- Introduced a callback mechanism for state changes to allow external handling of call end states.
- Updated pipeline and tests to accommodate new state management features, ensuring proper handling of call end scenarios.
This commit is contained in:
Xin Wang
2026-08-12 14:25:24 +08:00
parent d0bb5b3868
commit 79a239489a
5 changed files with 234 additions and 12 deletions
+21 -1
View File
@@ -63,11 +63,19 @@ def playback_marker_for(
return FixedSpeechPlaybackMarkerFrame(completion=completion)
CallEndStateHandler = Callable[[str, bool], Awaitable[None]]
class CallEndCoordinator:
"""Coordinate committed and user-cancellable conversation endings."""
def __init__(self, queue_end: Callable[[str], Awaitable[None]]):
def __init__(
self,
queue_end: Callable[[str], Awaitable[None]],
on_state_change: CallEndStateHandler | None = None,
):
self._queue_end = queue_end
self._on_state_change = on_state_change
self._ending = False
self._interruptible = False
self._armed = False
@@ -110,6 +118,7 @@ class CallEndCoordinator:
self._armed = False
self._finish_after_tracked_speech = False
self._reason = reason or "completed"
self._schedule_state("speaking" if self._speaking else "pending")
def cancel_pending_end(self) -> bool:
"""Cancel one pending ending when its tool explicitly allows it."""
@@ -123,8 +132,13 @@ class CallEndCoordinator:
# Transport speech state remains authoritative: if audio is currently
# playing, the interruption path will still emit BotStoppedSpeakingFrame.
logger.info("用户打断结束语,已撤销待挂断状态")
self._schedule_state("cancelled")
return True
def _schedule_state(self, state: str) -> None:
if self._on_state_change is not None:
asyncio.create_task(self._on_state_change(state, self.interruptible))
def begin_response(self) -> None:
"""Start tracking speech produced by one LLM response."""
self._response_speech_started = False
@@ -200,6 +214,8 @@ class CallEndCoordinator:
self._speaking = True
self._speech_stopped.clear()
self._response_speech_started = True
if self._ending:
await self._emit_state("speaking")
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
self._speaking = False
self._speech_stopped.set()
@@ -207,6 +223,10 @@ class CallEndCoordinator:
logger.info("结束语播报完毕,挂断通话")
await self.finish()
async def _emit_state(self, state: str) -> None:
if self._on_state_change is not None:
await self._on_state_change(state, self.interruptible)
class EndCallAfterSpeechProcessor(FrameProcessor):
def __init__(self, coordinator: CallEndCoordinator):
+53 -2
View File
@@ -201,6 +201,23 @@ def _multimodal_user_input_frame(
)
def _call_end_state_frame(
state: str,
*,
interruptible: bool,
voice_interruptible: bool,
) -> OutputTransportMessageUrgentFrame:
"""Build the client event used to render the pending call-end state."""
return OutputTransportMessageUrgentFrame(
message={
"type": "call-end-state",
"state": state,
"interruptible": interruptible,
"voice_interruptible": voice_interruptible,
}
)
async def run_pipeline(
transport,
cfg: AssistantConfig,
@@ -295,7 +312,24 @@ async def run_pipeline(
)
await worker.queue_frame(EndFrame())
call_end = CallEndCoordinator(queue_call_end)
async def queue_call_end_state(state: str, interruptible: bool) -> None:
worker = worker_holder.get("worker")
if worker is None:
return
await worker.queue_frame(
_call_end_state_frame(
state,
interruptible=interruptible,
voice_interruptible=(
interruptible and current_enable_interrupt
),
)
)
call_end = CallEndCoordinator(
queue_call_end,
on_state_change=queue_call_end_state,
)
knowledge_config = cfg.knowledge_retrieval_config
knowledge_mode = str(knowledge_config.get("mode", "automatic"))
@@ -976,7 +1010,24 @@ async def run_realtime_pipeline(
)
await worker.queue_frame(EndFrame())
call_end = CallEndCoordinator(queue_call_end)
async def queue_call_end_state(state: str, interruptible: bool) -> None:
worker = worker_holder.get("worker")
if worker is None:
return
await worker.queue_frame(
_call_end_state_frame(
state,
interruptible=interruptible,
voice_interruptible=(
interruptible and cfg.enableInterrupt
),
)
)
call_end = CallEndCoordinator(
queue_call_end,
on_state_change=queue_call_end_state,
)
client_tools = ClientToolBroker()
async def interrupt_for_client_tool_result() -> None: