feat: unify debug runtime timelines

This commit is contained in:
Xin Wang
2026-08-13 09:30:43 +08:00
parent 7c18cea16d
commit eb111a3890
21 changed files with 1380 additions and 533 deletions
+63 -4
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from loguru import logger
from pipecat.frames.frames import (
@@ -14,6 +16,38 @@ from pipecat.frames.frames import (
InterruptionFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
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,
}
class SpeechPlaybackCompletion:
@@ -76,6 +110,7 @@ class CallEndCoordinator:
):
self._queue_end = queue_end
self._on_state_change = on_state_change
self._state_tasks: set[asyncio.Task[None]] = set()
self._ending = False
self._interruptible = False
self._armed = False
@@ -86,7 +121,7 @@ class CallEndCoordinator:
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
self._finish_after_tracked_speech = False
self._finished = False
self._reason = "completed"
self._reason = ""
@property
def ending(self) -> bool:
@@ -106,6 +141,12 @@ class CallEndCoordinator:
"""Whether transport output is currently producing bot speech."""
return self._speaking
@property
def reason(self) -> str:
"""Current requested ending reason for client feedback and records."""
return self._reason
async def wait_until_silent(self) -> None:
"""Wait for the transport-owned bot speech boundary."""
await self._speech_stopped.wait()
@@ -124,6 +165,7 @@ class CallEndCoordinator:
"""Cancel one pending ending when its tool explicitly allows it."""
if not self.interruptible:
return False
was_interruptible = self._interruptible
self._ending = False
self._interruptible = False
self._armed = False
@@ -132,12 +174,26 @@ 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")
self._schedule_state("cancelled", interruptible=was_interruptible)
return True
def _schedule_state(self, state: str) -> None:
def _schedule_state(
self,
state: str,
*,
interruptible: bool | None = None,
) -> None:
if self._on_state_change is not None:
asyncio.create_task(self._on_state_change(state, self.interruptible))
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)
def begin_response(self) -> None:
"""Start tracking speech produced by one LLM response."""
@@ -196,6 +252,9 @@ class CallEndCoordinator:
return
self._finished = True
self._interruptible = False
pending_states = tuple(self._state_tasks)
if pending_states:
await asyncio.gather(*pending_states, return_exceptions=True)
await self._queue_end(self._reason)
async def observe(self, frame) -> None: