2026-07-11 22:26:31 +08:00
|
|
|
"""Shared call termination timing for prompt tools and workflow end nodes."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-03 07:10:41 +08:00
|
|
|
import asyncio
|
2026-07-11 22:26:31 +08:00
|
|
|
from collections.abc import Awaitable, Callable
|
2026-08-06 10:21:36 +08:00
|
|
|
from dataclasses import dataclass
|
2026-08-13 09:30:43 +08:00
|
|
|
from typing import Any
|
|
|
|
|
from uuid import uuid4
|
2026-07-11 22:26:31 +08:00
|
|
|
|
|
|
|
|
from loguru import logger
|
2026-08-06 10:21:36 +08:00
|
|
|
from pipecat.frames.frames import (
|
|
|
|
|
BotStartedSpeakingFrame,
|
|
|
|
|
BotStoppedSpeakingFrame,
|
|
|
|
|
DataFrame,
|
|
|
|
|
InterruptionFrame,
|
|
|
|
|
)
|
2026-07-11 22:26:31 +08:00
|
|
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
2026-08-13 09:30:43 +08:00
|
|
|
from pipecat.utils.time import time_now_iso8601
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CALL_END_EVENT_BY_STATE = {
|
|
|
|
|
"pending": "conversation_end_requested",
|
|
|
|
|
"speaking": "conversation_end_speaking",
|
|
|
|
|
"cancelled": "conversation_end_cancelled",
|
|
|
|
|
"ended": "conversation_ended",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def call_end_trace_event(
|
|
|
|
|
state: str,
|
|
|
|
|
*,
|
|
|
|
|
reason: str = "",
|
|
|
|
|
interruptible: bool = False,
|
|
|
|
|
voice_interruptible: bool = False,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Build one shared record used by live calls and headless tests."""
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"type": "workflow-event",
|
|
|
|
|
"eventId": f"wfe_{uuid4().hex[:20]}",
|
|
|
|
|
"event": CALL_END_EVENT_BY_STATE.get(
|
|
|
|
|
state,
|
|
|
|
|
"conversation_end_state_changed",
|
|
|
|
|
),
|
|
|
|
|
"timestamp": time_now_iso8601(),
|
|
|
|
|
**({"reason": reason} if reason else {}),
|
|
|
|
|
"interruptible": interruptible,
|
|
|
|
|
"voiceInterruptible": voice_interruptible,
|
|
|
|
|
}
|
2026-07-11 22:26:31 +08:00
|
|
|
|
|
|
|
|
|
2026-08-06 10:21:36 +08:00
|
|
|
class SpeechPlaybackCompletion:
|
|
|
|
|
"""Awaitable completed by its exact marker at the transport output."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, coordinator: CallEndCoordinator):
|
|
|
|
|
self._coordinator = coordinator
|
|
|
|
|
self._future: asyncio.Future[None] = (
|
|
|
|
|
asyncio.get_running_loop().create_future()
|
|
|
|
|
)
|
|
|
|
|
self._queued = False
|
|
|
|
|
|
|
|
|
|
def __await__(self):
|
|
|
|
|
return self._future.__await__()
|
|
|
|
|
|
|
|
|
|
def done(self) -> bool:
|
|
|
|
|
return self._future.done()
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def queued(self) -> bool:
|
|
|
|
|
return self._queued
|
|
|
|
|
|
|
|
|
|
def mark_queued(self) -> None:
|
|
|
|
|
self._queued = True
|
|
|
|
|
|
|
|
|
|
async def mark_played(self) -> None:
|
|
|
|
|
await self._coordinator.complete_tracked_speech(self)
|
|
|
|
|
|
|
|
|
|
def _resolve(self) -> None:
|
|
|
|
|
if not self._future.done():
|
|
|
|
|
self._future.set_result(None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class FixedSpeechPlaybackMarkerFrame(DataFrame):
|
|
|
|
|
"""Ordered frame that identifies one fixed utterance at transport output."""
|
|
|
|
|
|
|
|
|
|
completion: SpeechPlaybackCompletion
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def playback_marker_for(
|
|
|
|
|
completion: Awaitable[None] | None,
|
|
|
|
|
) -> FixedSpeechPlaybackMarkerFrame | None:
|
|
|
|
|
"""Build a marker only for the production call-end coordinator."""
|
|
|
|
|
if not isinstance(completion, SpeechPlaybackCompletion):
|
|
|
|
|
return None
|
|
|
|
|
return FixedSpeechPlaybackMarkerFrame(completion=completion)
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 14:25:24 +08:00
|
|
|
CallEndStateHandler = Callable[[str, bool], Awaitable[None]]
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 22:26:31 +08:00
|
|
|
class CallEndCoordinator:
|
2026-08-12 09:18:40 +08:00
|
|
|
"""Coordinate committed and user-cancellable conversation endings."""
|
2026-07-11 22:26:31 +08:00
|
|
|
|
2026-08-12 14:25:24 +08:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
queue_end: Callable[[str], Awaitable[None]],
|
|
|
|
|
on_state_change: CallEndStateHandler | None = None,
|
|
|
|
|
):
|
2026-07-11 22:26:31 +08:00
|
|
|
self._queue_end = queue_end
|
2026-08-12 14:25:24 +08:00
|
|
|
self._on_state_change = on_state_change
|
2026-08-13 09:30:43 +08:00
|
|
|
self._state_tasks: set[asyncio.Task[None]] = set()
|
2026-07-11 22:26:31 +08:00
|
|
|
self._ending = False
|
2026-08-12 09:18:40 +08:00
|
|
|
self._interruptible = False
|
2026-07-11 22:26:31 +08:00
|
|
|
self._armed = False
|
|
|
|
|
self._speaking = False
|
2026-08-04 09:30:52 +08:00
|
|
|
self._speech_stopped = asyncio.Event()
|
|
|
|
|
self._speech_stopped.set()
|
2026-07-14 13:43:42 +08:00
|
|
|
self._response_speech_started = False
|
2026-08-06 10:21:36 +08:00
|
|
|
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
|
2026-07-17 22:37:15 +08:00
|
|
|
self._finish_after_tracked_speech = False
|
2026-07-11 22:26:31 +08:00
|
|
|
self._finished = False
|
2026-08-13 09:30:43 +08:00
|
|
|
self._reason = ""
|
2026-07-11 22:26:31 +08:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def ending(self) -> bool:
|
|
|
|
|
return self._ending
|
|
|
|
|
|
2026-08-12 09:18:40 +08:00
|
|
|
@property
|
|
|
|
|
def accepts_input(self) -> bool:
|
|
|
|
|
"""Keep input open while an interruptible ending is still pending."""
|
|
|
|
|
return not self._finished and (not self._ending or self._interruptible)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def interruptible(self) -> bool:
|
|
|
|
|
return self._ending and self._interruptible and not self._finished
|
|
|
|
|
|
2026-08-04 09:30:52 +08:00
|
|
|
@property
|
|
|
|
|
def speaking(self) -> bool:
|
|
|
|
|
"""Whether transport output is currently producing bot speech."""
|
|
|
|
|
return self._speaking
|
|
|
|
|
|
2026-08-13 09:30:43 +08:00
|
|
|
@property
|
|
|
|
|
def reason(self) -> str:
|
|
|
|
|
"""Current requested ending reason for client feedback and records."""
|
|
|
|
|
|
|
|
|
|
return self._reason
|
|
|
|
|
|
2026-08-04 09:30:52 +08:00
|
|
|
async def wait_until_silent(self) -> None:
|
|
|
|
|
"""Wait for the transport-owned bot speech boundary."""
|
|
|
|
|
await self._speech_stopped.wait()
|
|
|
|
|
|
2026-08-12 09:18:40 +08:00
|
|
|
def begin(self, reason: str, *, interruptible: bool = False) -> None:
|
|
|
|
|
if self._finished:
|
|
|
|
|
return
|
2026-07-11 22:26:31 +08:00
|
|
|
self._ending = True
|
2026-08-12 09:18:40 +08:00
|
|
|
self._interruptible = interruptible
|
|
|
|
|
self._armed = False
|
|
|
|
|
self._finish_after_tracked_speech = False
|
2026-07-11 22:26:31 +08:00
|
|
|
self._reason = reason or "completed"
|
2026-08-12 14:25:24 +08:00
|
|
|
self._schedule_state("speaking" if self._speaking else "pending")
|
2026-07-11 22:26:31 +08:00
|
|
|
|
2026-08-12 09:18:40 +08:00
|
|
|
def cancel_pending_end(self) -> bool:
|
|
|
|
|
"""Cancel one pending ending when its tool explicitly allows it."""
|
|
|
|
|
if not self.interruptible:
|
|
|
|
|
return False
|
2026-08-13 09:30:43 +08:00
|
|
|
was_interruptible = self._interruptible
|
2026-08-12 09:18:40 +08:00
|
|
|
self._ending = False
|
|
|
|
|
self._interruptible = False
|
|
|
|
|
self._armed = False
|
|
|
|
|
self._finish_after_tracked_speech = False
|
|
|
|
|
self._response_speech_started = False
|
|
|
|
|
# Transport speech state remains authoritative: if audio is currently
|
|
|
|
|
# playing, the interruption path will still emit BotStoppedSpeakingFrame.
|
|
|
|
|
logger.info("用户打断结束语,已撤销待挂断状态")
|
2026-08-13 09:30:43 +08:00
|
|
|
self._schedule_state("cancelled", interruptible=was_interruptible)
|
2026-08-12 09:18:40 +08:00
|
|
|
return True
|
|
|
|
|
|
2026-08-13 09:30:43 +08:00
|
|
|
def _schedule_state(
|
|
|
|
|
self,
|
|
|
|
|
state: str,
|
|
|
|
|
*,
|
|
|
|
|
interruptible: bool | None = None,
|
|
|
|
|
) -> None:
|
2026-08-12 14:25:24 +08:00
|
|
|
if self._on_state_change is not None:
|
2026-08-13 09:30:43 +08:00
|
|
|
task = asyncio.create_task(
|
|
|
|
|
self._on_state_change(
|
|
|
|
|
state,
|
|
|
|
|
self.interruptible
|
|
|
|
|
if interruptible is None
|
|
|
|
|
else interruptible,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self._state_tasks.add(task)
|
|
|
|
|
task.add_done_callback(self._state_tasks.discard)
|
2026-08-12 14:25:24 +08:00
|
|
|
|
2026-07-14 13:43:42 +08:00
|
|
|
def begin_response(self) -> None:
|
|
|
|
|
"""Start tracking speech produced by one LLM response."""
|
|
|
|
|
self._response_speech_started = False
|
|
|
|
|
|
2026-07-11 22:26:31 +08:00
|
|
|
def arm_after_speech(self) -> None:
|
2026-07-14 13:43:42 +08:00
|
|
|
"""Wait for the next observed bot speech to finish."""
|
2026-08-12 09:18:40 +08:00
|
|
|
if not self._ending:
|
|
|
|
|
return
|
2026-07-14 13:43:42 +08:00
|
|
|
self._armed = True
|
|
|
|
|
|
2026-08-06 10:21:36 +08:00
|
|
|
def track_speech(self) -> SpeechPlaybackCompletion:
|
2026-08-03 07:10:41 +08:00
|
|
|
"""Register fixed speech and return its transport completion signal."""
|
2026-08-06 10:21:36 +08:00
|
|
|
completion = SpeechPlaybackCompletion(self)
|
|
|
|
|
self._tracked_speech_completions.add(completion)
|
2026-08-03 07:10:41 +08:00
|
|
|
return completion
|
2026-07-17 22:37:15 +08:00
|
|
|
|
2026-08-06 10:21:36 +08:00
|
|
|
async def complete_tracked_speech(
|
|
|
|
|
self,
|
|
|
|
|
completion: SpeechPlaybackCompletion,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Complete one fixed utterance when its marker reaches output."""
|
|
|
|
|
if completion not in self._tracked_speech_completions:
|
|
|
|
|
return
|
|
|
|
|
self._tracked_speech_completions.remove(completion)
|
|
|
|
|
completion._resolve()
|
|
|
|
|
if (
|
|
|
|
|
self._finish_after_tracked_speech
|
|
|
|
|
and not self._tracked_speech_completions
|
|
|
|
|
):
|
|
|
|
|
logger.info("所有工作流结束语播报完毕,挂断通话")
|
|
|
|
|
await self.finish()
|
|
|
|
|
|
2026-07-17 22:37:15 +08:00
|
|
|
async def arm_after_tracked_speech(self) -> None:
|
|
|
|
|
"""Finish after every already queued fixed utterance has played."""
|
2026-08-12 09:18:40 +08:00
|
|
|
if not self._ending:
|
|
|
|
|
return
|
2026-07-17 22:37:15 +08:00
|
|
|
self._finish_after_tracked_speech = True
|
2026-08-06 10:21:36 +08:00
|
|
|
if not self._tracked_speech_completions:
|
2026-07-17 22:37:15 +08:00
|
|
|
await self.finish()
|
|
|
|
|
|
2026-07-14 13:43:42 +08:00
|
|
|
async def finish_after_current_speech(self, *, has_text: bool) -> None:
|
|
|
|
|
"""Finish now if speech is absent/done, otherwise wait for its stop."""
|
2026-08-12 09:18:40 +08:00
|
|
|
if not self._ending:
|
|
|
|
|
return
|
2026-07-14 13:43:42 +08:00
|
|
|
if not has_text:
|
|
|
|
|
await self.finish()
|
|
|
|
|
return
|
|
|
|
|
if self._response_speech_started and not self._speaking:
|
|
|
|
|
await self.finish()
|
|
|
|
|
return
|
2026-07-11 22:26:31 +08:00
|
|
|
self._armed = True
|
|
|
|
|
|
|
|
|
|
async def finish(self) -> None:
|
2026-08-12 09:18:40 +08:00
|
|
|
if self._finished or not self._ending:
|
2026-07-11 22:26:31 +08:00
|
|
|
return
|
|
|
|
|
self._finished = True
|
2026-08-12 09:18:40 +08:00
|
|
|
self._interruptible = False
|
2026-08-13 09:30:43 +08:00
|
|
|
pending_states = tuple(self._state_tasks)
|
|
|
|
|
if pending_states:
|
|
|
|
|
await asyncio.gather(*pending_states, return_exceptions=True)
|
2026-07-11 22:26:31 +08:00
|
|
|
await self._queue_end(self._reason)
|
|
|
|
|
|
|
|
|
|
async def observe(self, frame) -> None:
|
2026-08-06 10:21:36 +08:00
|
|
|
if isinstance(frame, InterruptionFrame):
|
|
|
|
|
# Pipecat discards queued data frames on interruption, including
|
|
|
|
|
# playback markers. Treat already queued fixed speech as stopped so
|
|
|
|
|
# an interrupted Message cannot block a later EndNode forever.
|
|
|
|
|
interrupted = tuple(
|
|
|
|
|
completion
|
|
|
|
|
for completion in self._tracked_speech_completions
|
|
|
|
|
if completion.queued
|
|
|
|
|
)
|
|
|
|
|
for completion in interrupted:
|
|
|
|
|
await self.complete_tracked_speech(completion)
|
|
|
|
|
elif isinstance(frame, BotStartedSpeakingFrame):
|
2026-07-11 22:26:31 +08:00
|
|
|
self._speaking = True
|
2026-08-04 09:30:52 +08:00
|
|
|
self._speech_stopped.clear()
|
2026-07-14 13:43:42 +08:00
|
|
|
self._response_speech_started = True
|
2026-08-12 14:25:24 +08:00
|
|
|
if self._ending:
|
|
|
|
|
await self._emit_state("speaking")
|
2026-07-14 13:43:42 +08:00
|
|
|
elif isinstance(frame, BotStoppedSpeakingFrame) and self._speaking:
|
|
|
|
|
self._speaking = False
|
2026-08-04 09:30:52 +08:00
|
|
|
self._speech_stopped.set()
|
2026-08-06 10:21:36 +08:00
|
|
|
if self._armed:
|
2026-07-14 13:43:42 +08:00
|
|
|
logger.info("结束语播报完毕,挂断通话")
|
|
|
|
|
await self.finish()
|
2026-07-11 22:26:31 +08:00
|
|
|
|
2026-08-12 14:25:24 +08:00
|
|
|
async def _emit_state(self, state: str) -> None:
|
|
|
|
|
if self._on_state_change is not None:
|
|
|
|
|
await self._on_state_change(state, self.interruptible)
|
|
|
|
|
|
2026-07-11 22:26:31 +08:00
|
|
|
|
|
|
|
|
class EndCallAfterSpeechProcessor(FrameProcessor):
|
|
|
|
|
def __init__(self, coordinator: CallEndCoordinator):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self._coordinator = coordinator
|
|
|
|
|
|
|
|
|
|
async def process_frame(self, frame, direction: FrameDirection):
|
|
|
|
|
await super().process_frame(frame, direction)
|
|
|
|
|
await self.push_frame(frame, direction)
|
|
|
|
|
await self._coordinator.observe(frame)
|