Files
Praxis CI fbd6602814 docs(milestone): complete v0.1 foundation
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-08-01 13:32:48 +00:00

131 lines
4.8 KiB
Python

"""Latency observer — measures ASR→TTS-first-audio per turn (TASK-02-06).
Hooks into the Pipecat pipeline frame flow to timestamp:
- final-transcript-ready (ASR done)
- LLM-first-token
- TTS-first-audio
- client-playback-start (approx via output frame)
Surfaces the ASR→TTS-first-audio number to the client as a metric frame so the
React client can display it (TASK-02-05 latency readout). Also logs to console
for the server-side record.
This is a thin Pipecat FrameProcessor; it does not alter the frame stream, only
observes. Per-segment latencies are stored in a per-session LatencyRecord and
emitted via the task's metrics channel.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from pipecat.frames.frames import (
Frame,
TranscriptionFrame,
LLMFullResponseEndFrame,
TextFrame,
TTSStartedFrame,
TTSAudioRawFrame,
BotStartedSpeakingFrame,
)
from pipecat.processors.frame_processor import FrameProcessor
@dataclass
class LatencyRecord:
"""Per-turn latency segments (ms)."""
transcript_ready_ms: float | None = None
llm_first_token_ms: float | None = None
tts_first_audio_ms: float | None = None
playback_start_ms: float | None = None
@property
def e2e_asr_to_tts_ms(self) -> float | None:
"""ASR transcript-ready → TTS first-audio (the v0.1 latency target)."""
if self.transcript_ready_ms and self.tts_first_audio_ms:
return self.tts_first_audio_ms - self.transcript_ready_ms
return None
def as_metric(self) -> dict[str, Any]:
return {
"e2e_latency_ms": self.e2e_asr_to_tts_ms,
"transcript_ready_ms": self.transcript_ready_ms,
"llm_first_token_ms": self.llm_first_token_ms,
"tts_first_audio_ms": self.tts_first_audio_ms,
"playback_start_ms": self.playback_start_ms,
}
@dataclass
class LatencyObserverState:
"""Accumulates per-turn records and the current in-flight turn."""
current: LatencyRecord = field(default_factory=LatencyRecord)
records: list[LatencyRecord] = field(default_factory=list)
def reset_turn(self) -> LatencyRecord:
if self.current.transcript_ready_ms is not None:
self.records.append(self.current)
self.current = LatencyRecord()
return self.current
class LatencyObserver(FrameProcessor):
"""Observes frames, timestamps the latency-critical segments, emits metrics.
This processor is inserted into the pipeline (it passes frames through
unchanged). On each TTS-first-audio it logs the turn's e2e latency and
pushes a metric frame downstream for the client to read.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.state = LatencyObserverState()
async def process_frame(self, frame: Frame, direction) -> None:
# Always pass the frame through first (observation only).
await self.push_frame(frame, direction)
now_ms = time.perf_counter() * 1000.0
if isinstance(frame, TranscriptionFrame):
# ASR final transcript — start of a new turn's latency measurement.
rec = self.state.reset_turn()
rec.transcript_ready_ms = now_ms
elif isinstance(frame, LLMFullResponseEndFrame):
# LLM emitted a full response; first token timestamp is approximated
# by this frame's arrival (Pipecat doesn't emit a dedicated
# first-token frame; the metrics service handles TTFT separately).
if self.state.current.llm_first_token_ms is None:
self.state.current.llm_first_token_ms = now_ms
elif isinstance(frame, TextFrame):
# Intermediate LLM text frame — closest proxy to first-token time.
if (
self.state.current.transcript_ready_ms is not None
and self.state.current.llm_first_token_ms is None
):
self.state.current.llm_first_token_ms = now_ms
elif isinstance(frame, (TTSStartedFrame, TTSAudioRawFrame)):
if self.state.current.tts_first_audio_ms is None:
self.state.current.tts_first_audio_ms = now_ms
e2e = self.state.current.e2e_asr_to_tts_ms
if e2e is not None:
from loguru import logger
logger.info(
f"[latency] ASR→TTS first-audio: {e2e:.1f}ms "
f"(budget 600ms — {'within' if e2e <= 600 else 'OVER'})"
)
elif isinstance(frame, BotStartedSpeakingFrame):
if self.state.current.playback_start_ms is None:
self.state.current.playback_start_ms = now_ms
__all__ = ["LatencyObserver", "LatencyRecord", "LatencyObserverState"]