Merge branch 'main' into pk/service-settings-refactor

This commit is contained in:
kompfner
2026-02-20 21:58:41 -05:00
committed by Paul Kompfner
78 changed files with 4647 additions and 1411 deletions

View File

@@ -12,10 +12,13 @@ the Koala filter and integrates with Pipecat's input transport pipeline.
Classes:
AICFilter: For aic-sdk (uses 'aic_sdk' module)
AICModelManager: Singleton manager for read-only AIC Model instances.
"""
import asyncio
from pathlib import Path
from typing import List, Optional
from threading import Lock
from typing import List, Optional, Tuple
import numpy as np
from aic_sdk import (
@@ -33,6 +36,177 @@ from pipecat.audio.vad.aic_vad import AICVADAnalyzer
from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame
class AICModelManager:
"""Singleton manager for read-only AIC Model instances with reference counting.
Caches Model instances by path or (model_id + download_dir). Multiple
AICFilter instances using the same model share one Model; the manager
acquires on first use and releases when the last reference is dropped.
"""
_cache: dict[str, Tuple[Model, int]] = {} # key -> (model, ref_count)
_lock = Lock()
_loading: dict[
str, asyncio.Task[Model]
] = {} # key -> load task (deduplicates concurrent loads)
@classmethod
def _increment_reference(cls, cache_key: str, entry: Tuple[Model, int]) -> Tuple[Model, str]:
"""Increment reference count for cached entry. Caller must hold _lock."""
cached_model, ref_count = entry
cls._cache[cache_key] = (cached_model, ref_count + 1)
logger.debug(f"AIC model cache key={cache_key!r} ref_count={ref_count + 1}")
return cached_model, cache_key
@classmethod
def _store_new_reference(cls, cache_key: str, model: Model) -> Tuple[Model, str]:
"""Store new model in cache with ref count 1. Caller must hold _lock."""
cls._cache[cache_key] = (model, 1)
logger.debug(f"AIC model cached key={cache_key!r} ref_count=1")
return model, cache_key
@classmethod
async def _load_model_from_file(
cls,
cache_key: str,
*,
model_path: Optional[Path] = None,
model_id: Optional[str] = None,
model_download_dir: Optional[Path] = None,
) -> Model:
"""Run the actual load (file or download). Separate to allow create_task and deduplication."""
if model_path is not None:
logger.debug(f"Loading AIC model from file: {model_path}")
model_path_str = str(model_path)
elif model_id is not None and model_download_dir is not None:
logger.debug(f"Downloading AIC model: {model_id}")
model_download_dir.mkdir(parents=True, exist_ok=True)
model_path_str = await Model.download_async(model_id, str(model_download_dir))
logger.debug(f"Model downloaded to: {model_path_str}")
else:
raise ValueError("Unexpected model_path or (model_id and model_download_dir) state.")
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: Model.from_file(model_path_str))
@staticmethod
def _get_cache_key(
*,
model_path: Optional[Path] = None,
model_id: Optional[str] = None,
model_download_dir: Optional[Path] = None,
) -> str:
"""Build a stable cache key for the model.
Args:
model_path: Path to a local .aicmodel file.
model_id: Model identifier (See https://artifacts.ai-coustics.io/ for available models).
model_download_dir: Directory used for downloading models.
Returns:
A string key unique per (path) or (model_id + download_dir).
"""
if model_path is not None:
return f"path:{model_path.resolve()}"
if model_id is not None and model_download_dir is not None:
return f"id:{model_id}:{model_download_dir.resolve()}"
raise ValueError("Either model_path or (model_id and model_download_dir) must be set.")
@classmethod
async def acquire(
cls,
*,
model_path: Optional[Path] = None,
model_id: Optional[str] = None,
model_download_dir: Optional[Path] = None,
) -> Tuple[Model, str]:
"""Get or load a Model and increment its reference count.
Call this when starting a filter. Store the returned key and pass it
to release() when stopping the filter.
Args:
model_path: Path to a local .aicmodel file. If set, model_id is ignored.
model_id: Model identifier to download from CDN.
model_download_dir: Directory for downloading models. Required if
model_id is used.
Returns:
Tuple of (shared Model instance, cache key for release).
Raises:
ValueError: If neither model_path nor (model_id + model_download_dir)
is provided, or if model_id is set without model_download_dir.
"""
cache_key = cls._get_cache_key(
model_path=model_path,
model_id=model_id,
model_download_dir=model_download_dir,
)
with cls._lock:
entry = cls._cache.get(cache_key)
if entry is not None:
return cls._increment_reference(cache_key, entry)
# Deduplicate concurrent loads for the same key
load_task = cls._loading.get(cache_key)
if load_task is None:
load_task = asyncio.create_task(
cls._load_model_from_file(
cache_key,
model_path=model_path,
model_id=model_id,
model_download_dir=model_download_dir,
)
)
cls._loading[cache_key] = load_task
try:
model = await load_task
finally:
with cls._lock:
cls._loading.pop(cache_key, None)
with cls._lock:
entry = cls._cache.get(cache_key)
if entry is not None:
return cls._increment_reference(cache_key, entry)
return cls._store_new_reference(cache_key, model)
@classmethod
def release(cls, key: str) -> None:
"""Release a reference to a cached model.
Call this when stopping a filter, with the key returned from
get_model(). When the last reference is released, the model
is removed from the cache.
Args:
key: Cache key returned by get_model().
"""
with cls._lock:
entry = cls._cache.get(key)
if entry is None:
logger.warning(f"AIC model release unknown key={key!r}")
return
model, ref_count = entry
ref_count -= 1
if ref_count <= 0:
del cls._cache[key]
logger.debug(f"AIC model evicted key={key!r}")
else:
cls._cache[key] = (model, ref_count)
logger.debug(f"AIC model key={key!r} ref_count={ref_count}")
class AICFilter(BaseAudioFilter):
"""Audio filter using ai-coustics' AIC SDK for real-time enhancement.
@@ -91,7 +265,8 @@ class AICFilter(BaseAudioFilter):
32768.0 # 2^15, for normalizing int16 (-32768 to 32767) to float32 (-1.0 to 1.0)
)
# AIC SDK objects
# AIC SDK objects; model is shared via AICModelManager
self._model_cache_key: Optional[str] = None
self._model = None
self._processor = None
self._processor_ctx = None
@@ -162,16 +337,12 @@ class AICFilter(BaseAudioFilter):
"""
self._sample_rate = sample_rate
# Load or download model
if self._model_path:
logger.debug(f"Loading AIC model from: {self._model_path}")
self._model = Model.from_file(str(self._model_path))
else:
logger.debug(f"Downloading AIC model: {self._model_id}")
self._model_download_dir.mkdir(parents=True, exist_ok=True)
model_path = await Model.download_async(self._model_id, str(self._model_download_dir))
logger.debug(f"Model downloaded to: {model_path}")
self._model = Model.from_file(model_path)
# Acquire shared read-only model from singleton manager
self._model, self._model_cache_key = await AICModelManager.acquire(
model_path=self._model_path,
model_id=self._model_id,
model_download_dir=self._model_download_dir,
)
# Get optimal frames for this sample rate
self._frames_per_block = self._model.get_optimal_num_frames(self._sample_rate)
@@ -242,6 +413,10 @@ class AICFilter(BaseAudioFilter):
self._aic_ready = False
self._audio_buffer.clear()
if self._model_cache_key is not None:
AICModelManager.release(self._model_cache_key)
self._model_cache_key = None
async def process_frame(self, frame: FilterControlFrame):
"""Process control frames to enable/disable filtering.

View File

@@ -43,6 +43,7 @@ if TYPE_CHECKING:
from pipecat.processors.aggregators.llm_context import LLMContext, NotGiven
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.settings import ServiceSettings
from pipecat.utils.tracing.tracing_context import TracingContext
class DeprecatedKeypadEntry:
@@ -123,6 +124,9 @@ class Frame:
id: Unique identifier for the frame instance.
name: Human-readable name combining class name and instance count.
pts: Presentation timestamp in nanoseconds.
broadcast_sibling_id: ID of the paired frame when this frame was
broadcast in both directions. Set automatically by
``broadcast_frame()`` and ``broadcast_frame_instance()``.
metadata: Dictionary for arbitrary frame metadata.
transport_source: Name of the transport source that created this frame.
transport_destination: Name of the transport destination for this frame.
@@ -131,6 +135,7 @@ class Frame:
id: int = field(init=False)
name: str = field(init=False)
pts: Optional[int] = field(init=False)
broadcast_sibling_id: Optional[int] = field(init=False)
metadata: Dict[str, Any] = field(init=False)
transport_source: Optional[str] = field(init=False)
transport_destination: Optional[str] = field(init=False)
@@ -139,6 +144,7 @@ class Frame:
self.id: int = obj_id()
self.name: str = f"{self.__class__.__name__}#{obj_count(self)}"
self.pts: Optional[int] = None
self.broadcast_sibling_id: Optional[int] = None
self.metadata: Dict[str, Any] = {}
self.transport_source: Optional[str] = None
self.transport_destination: Optional[str] = None
@@ -1037,6 +1043,7 @@ class StartFrame(SystemFrame):
Use `LLMUserAggregator`'s new `user_turn_strategies` parameter instead.
report_only_initial_ttfb: Whether to report only initial time-to-first-byte.
tracing_context: Pipeline-scoped tracing context for span hierarchy.
"""
audio_in_sample_rate: int = 16000
@@ -1047,6 +1054,7 @@ class StartFrame(SystemFrame):
enable_usage_metrics: bool = False
interruption_strategies: List[BaseInterruptionStrategy] = field(default_factory=list)
report_only_initial_ttfb: bool = False
tracing_context: Optional["TracingContext"] = None
@dataclass
@@ -2151,6 +2159,20 @@ class STTUpdateSettingsFrame(ServiceUpdateSettingsFrame):
pass
@dataclass
class UserIdleTimeoutUpdateFrame(SystemFrame):
"""Frame for updating the user idle timeout at runtime.
Setting timeout to 0 disables idle detection. Setting a positive value
enables it.
Parameters:
timeout: The new idle timeout in seconds. 0 disables idle detection.
"""
timeout: float
@dataclass
class VADParamsUpdateFrame(ControlFrame):
"""Frame for updating VAD parameters.

View File

@@ -53,6 +53,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, F
from pipecat.processors.frameworks.rtvi import RTVIObserver, RTVIObserverParams, RTVIProcessor
from pipecat.utils.asyncio.task_manager import BaseTaskManager, TaskManager, TaskManagerParams
from pipecat.utils.tracing.setup import is_tracing_available
from pipecat.utils.tracing.tracing_context import TracingContext
from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver
HEARTBEAT_SECS = 1.0
@@ -290,10 +291,13 @@ class PipelineTask(BasePipelineTask):
self._turn_tracking_observer: Optional[TurnTrackingObserver] = None
self._user_bot_latency_observer: Optional[UserBotLatencyObserver] = None
self._turn_trace_observer: Optional[TurnTraceObserver] = None
self._tracing_context: Optional[TracingContext] = None
if self._enable_turn_tracking:
self._turn_tracking_observer = TurnTrackingObserver()
observers.append(self._turn_tracking_observer)
if self._enable_tracing and self._turn_tracking_observer:
# Create pipeline-scoped tracing context
self._tracing_context = TracingContext()
# Create latency observer for tracing
self._user_bot_latency_observer = UserBotLatencyObserver()
observers.append(self._user_bot_latency_observer)
@@ -303,6 +307,7 @@ class PipelineTask(BasePipelineTask):
latency_tracker=self._user_bot_latency_observer,
conversation_id=self._conversation_id,
additional_span_attributes=self._additional_span_attributes,
tracing_context=self._tracing_context,
)
observers.append(self._turn_trace_observer)
@@ -813,6 +818,7 @@ class PipelineTask(BasePipelineTask):
enable_usage_metrics=self._params.enable_usage_metrics,
report_only_initial_ttfb=self._params.report_only_initial_ttfb,
interruption_strategies=self._params.interruption_strategies,
tracing_context=self._tracing_context,
)
start_frame.metadata = self._create_start_metadata()
await self._pipeline.queue_frame(start_frame)

View File

@@ -92,9 +92,9 @@ class LLMUserAggregatorParams:
user_mute_strategies: List of user mute strategies.
user_turn_stop_timeout: Time in seconds to wait before considering the
user's turn finished.
user_idle_timeout: Optional timeout in seconds for detecting user idle state.
If set, the aggregator will emit an `on_user_turn_idle` event when the user
has been idle (not speaking) for this duration. Set to None to disable
user_idle_timeout: Timeout in seconds for detecting user idle state.
The aggregator will emit an `on_user_turn_idle` event when the user
has been idle (not speaking) for this duration. Set to 0 to disable
idle detection.
vad_analyzer: Voice Activity Detection analyzer instance.
filter_incomplete_user_turns: Whether to filter out incomplete user turns.
@@ -109,7 +109,7 @@ class LLMUserAggregatorParams:
user_turn_strategies: Optional[UserTurnStrategies] = None
user_mute_strategies: List[BaseUserMuteStrategy] = field(default_factory=list)
user_turn_stop_timeout: float = 5.0
user_idle_timeout: Optional[float] = None
user_idle_timeout: float = 0
vad_analyzer: Optional[VADAnalyzer] = None
filter_incomplete_user_turns: bool = False
user_turn_completion_config: Optional[UserTurnCompletionConfig] = None
@@ -404,15 +404,10 @@ class LLMUserAggregator(LLMContextAggregator):
"on_user_turn_stop_timeout", self._on_user_turn_stop_timeout
)
# Optional user idle controller
self._user_idle_controller: Optional[UserIdleController] = None
if self._params.user_idle_timeout:
self._user_idle_controller = UserIdleController(
user_idle_timeout=self._params.user_idle_timeout
)
self._user_idle_controller.add_event_handler(
"on_user_turn_idle", self._on_user_turn_idle
)
self._user_idle_controller = UserIdleController(
user_idle_timeout=self._params.user_idle_timeout
)
self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle)
# VAD controller
self._vad_controller: Optional[VADController] = None
@@ -489,8 +484,7 @@ class LLMUserAggregator(LLMContextAggregator):
await self._user_turn_controller.process_frame(frame)
if self._user_idle_controller:
await self._user_idle_controller.process_frame(frame)
await self._user_idle_controller.process_frame(frame)
async def push_aggregation(self) -> str:
"""Push the current aggregation."""
@@ -507,8 +501,7 @@ class LLMUserAggregator(LLMContextAggregator):
async def _start(self, frame: StartFrame):
await self._user_turn_controller.setup(self.task_manager)
if self._user_idle_controller:
await self._user_idle_controller.setup(self.task_manager)
await self._user_idle_controller.setup(self.task_manager)
for s in self._params.user_mute_strategies:
await s.setup(self.task_manager)
@@ -541,14 +534,19 @@ class LLMUserAggregator(LLMContextAggregator):
async def _cleanup(self):
await self._user_turn_controller.cleanup()
if self._user_idle_controller:
await self._user_idle_controller.cleanup()
await self._user_idle_controller.cleanup()
for s in self._params.user_mute_strategies:
await s.cleanup()
async def _maybe_mute_frame(self, frame: Frame):
# Lifecycle frames should never be muted and should not trigger mute
# state changes. Evaluating mute strategies on StartFrame would
# broadcast UserMuteStartedFrame before StartFrame reaches downstream
# processors.
if isinstance(frame, (StartFrame, EndFrame, CancelFrame)):
return False
should_mute_frame = self._user_is_muted and isinstance(
frame,
(
@@ -689,6 +687,8 @@ class LLMUserAggregator(LLMContextAggregator):
if params.enable_user_speaking_frames:
await self.broadcast_frame(UserStartedSpeakingFrame)
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
if params.enable_interruptions and self._allow_interruptions:
await self.push_interruption_task_frame_and_wait()
@@ -705,6 +705,8 @@ class LLMUserAggregator(LLMContextAggregator):
if params.enable_user_speaking_frames:
await self.broadcast_frame(UserStoppedSpeakingFrame)
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
await self._maybe_emit_user_turn_stopped(strategy)
async def _on_user_turn_stop_timeout(self, controller):
@@ -1255,8 +1257,8 @@ class LLMContextAggregatorPair:
self,
context: LLMContext,
*,
user_params: LLMUserAggregatorParams = LLMUserAggregatorParams(),
assistant_params: LLMAssistantAggregatorParams = LLMAssistantAggregatorParams(),
user_params: Optional[LLMUserAggregatorParams] = None,
assistant_params: Optional[LLMAssistantAggregatorParams] = None,
):
"""Initialize the LLM context aggregator pair.
@@ -1265,6 +1267,8 @@ class LLMContextAggregatorPair:
user_params: Parameters for the user context aggregator.
assistant_params: Parameters for the assistant context aggregator.
"""
user_params = user_params or LLMUserAggregatorParams()
assistant_params = assistant_params or LLMAssistantAggregatorParams()
self._user = LLMUserAggregator(context, params=user_params)
self._assistant = LLMAssistantAggregator(context, params=assistant_params)

View File

@@ -52,8 +52,6 @@ from pipecat.processors.metrics.frame_processor_metrics import FrameProcessorMet
from pipecat.utils.asyncio.task_manager import BaseTaskManager
from pipecat.utils.base_object import BaseObject
INTERRUPTION_COMPLETION_TIMEOUT = 2.0
class FrameDirection(Enum):
"""Direction of frame flow in the processing pipeline.
@@ -419,27 +417,49 @@ class FrameProcessor(BaseObject):
"""
self._metrics.set_core_metrics_data(data)
async def start_ttfb_metrics(self):
"""Start time-to-first-byte metrics collection."""
if self.can_generate_metrics() and self.metrics_enabled:
await self._metrics.start_ttfb_metrics(self._report_only_initial_ttfb)
async def start_ttfb_metrics(self, *, start_time: Optional[float] = None):
"""Start time-to-first-byte metrics collection.
async def stop_ttfb_metrics(self):
"""Stop time-to-first-byte metrics collection and push results."""
Args:
start_time: Optional timestamp to use as the start time. If None,
uses the current time.
"""
if self.can_generate_metrics() and self.metrics_enabled:
frame = await self._metrics.stop_ttfb_metrics()
await self._metrics.start_ttfb_metrics(
start_time=start_time, report_only_initial_ttfb=self._report_only_initial_ttfb
)
async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None):
"""Stop time-to-first-byte metrics collection and push results.
Args:
end_time: Optional timestamp to use as the end time. If None, uses
the current time.
"""
if self.can_generate_metrics() and self.metrics_enabled:
frame = await self._metrics.stop_ttfb_metrics(end_time=end_time)
if frame:
await self.push_frame(frame)
async def start_processing_metrics(self):
"""Start processing metrics collection."""
if self.can_generate_metrics() and self.metrics_enabled:
await self._metrics.start_processing_metrics()
async def start_processing_metrics(self, *, start_time: Optional[float] = None):
"""Start processing metrics collection.
async def stop_processing_metrics(self):
"""Stop processing metrics collection and push results."""
Args:
start_time: Optional timestamp to use as the start time. If None,
uses the current time.
"""
if self.can_generate_metrics() and self.metrics_enabled:
frame = await self._metrics.stop_processing_metrics()
await self._metrics.start_processing_metrics(start_time=start_time)
async def stop_processing_metrics(self, *, end_time: Optional[float] = None):
"""Stop processing metrics collection and push results.
Args:
end_time: Optional timestamp to use as the end time. If None, uses
the current time.
"""
if self.can_generate_metrics() and self.metrics_enabled:
frame = await self._metrics.stop_processing_metrics(end_time=end_time)
if frame:
await self.push_frame(frame)
@@ -741,7 +761,7 @@ class FrameProcessor(BaseObject):
await self._call_event_handler("on_after_push_frame", frame)
async def push_interruption_task_frame_and_wait(self):
async def push_interruption_task_frame_and_wait(self, *, timeout: float = 5.0):
"""Push an interruption task frame upstream and wait for the interruption.
This function sends an `InterruptionTaskFrame` upstream to the
@@ -750,9 +770,11 @@ class FrameProcessor(BaseObject):
attached to both frames so the caller can wait until the interruption
has fully traversed the pipeline. The event is set when the
`InterruptionFrame` reaches the pipeline sink. If the frame does
not complete within `INTERRUPTION_COMPLETION_TIMEOUT` seconds, a
warning is logged periodically until it completes.
not complete within the given timeout, a warning is logged and the
event is forcibly set so the caller is unblocked.
Args:
timeout: Maximum seconds to wait for the interruption to complete.
"""
self._wait_for_interruption = True
@@ -760,19 +782,20 @@ class FrameProcessor(BaseObject):
await self.push_frame(InterruptionTaskFrame(event=event), FrameDirection.UPSTREAM)
# Wait for the `InterruptionFrame` to complete and log a warning
# periodically if it takes too long.
# Wait for the `InterruptionFrame` to complete and log a warning if it
# takes too long. If it does take too long make sure we unblock it,
# otherwise we will hang here forever.
while not event.is_set():
try:
await asyncio.wait_for(event.wait(), timeout=INTERRUPTION_COMPLETION_TIMEOUT)
await asyncio.wait_for(event.wait(), timeout=timeout)
except asyncio.TimeoutError:
logger.warning(
f"{self}: InterruptionFrame has not completed after"
f" {INTERRUPTION_COMPLETION_TIMEOUT}s. Make sure"
" InterruptionFrame.complete() is being called (e.g. if the"
" frame is being blocked or consumed before reaching the"
" pipeline sink)."
f" {timeout}s. Make sure InterruptionFrame.complete()"
" is being called (e.g. if the frame is being blocked"
" or consumed before reaching the pipeline sink)."
)
event.set()
self._wait_for_interruption = False
@@ -787,8 +810,12 @@ class FrameProcessor(BaseObject):
frame_cls: The class of the frame to be broadcasted.
**kwargs: Keyword arguments to be passed to the frame's constructor.
"""
await self.push_frame(frame_cls(**kwargs))
await self.push_frame(frame_cls(**kwargs), FrameDirection.UPSTREAM)
downstream_frame = frame_cls(**kwargs)
upstream_frame = frame_cls(**kwargs)
downstream_frame.broadcast_sibling_id = upstream_frame.id
upstream_frame.broadcast_sibling_id = downstream_frame.id
await self.push_frame(downstream_frame)
await self.push_frame(upstream_frame, FrameDirection.UPSTREAM)
async def broadcast_frame_instance(self, frame: Frame):
"""Broadcasts a frame instance upstream and downstream.
@@ -812,15 +839,18 @@ class FrameProcessor(BaseObject):
if not f.init and f.name not in ("id", "name")
}
new_frame = frame_cls(**init_fields)
downstream_frame = frame_cls(**init_fields)
for k, v in extra_fields.items():
setattr(new_frame, k, v)
await self.push_frame(new_frame)
setattr(downstream_frame, k, v)
new_frame = frame_cls(**init_fields)
upstream_frame = frame_cls(**init_fields)
for k, v in extra_fields.items():
setattr(new_frame, k, v)
await self.push_frame(new_frame, FrameDirection.UPSTREAM)
setattr(upstream_frame, k, v)
downstream_frame.broadcast_sibling_id = upstream_frame.id
upstream_frame.broadcast_sibling_id = downstream_frame.id
await self.push_frame(downstream_frame)
await self.push_frame(upstream_frame, FrameDirection.UPSTREAM)
async def __start(self, frame: StartFrame):
"""Handle the start frame to initialize processor state.

View File

@@ -25,6 +25,7 @@ from typing import (
Literal,
Mapping,
Optional,
Set,
Tuple,
Union,
)
@@ -1026,6 +1027,11 @@ class RTVIObserverParams:
metrics_enabled: Indicates if metrics messages should be sent.
system_logs_enabled: Indicates if system logs should be sent.
errors_enabled: [Deprecated] Indicates if errors messages should be sent.
ignored_sources: List of frame processors whose frames should be silently ignored
by this observer. Useful for suppressing RTVI messages from secondary pipeline
branches (e.g. a silent evaluation LLM) that should not be visible to clients.
Sources can also be added and removed dynamically via ``add_ignored_source()``
and ``remove_ignored_source()``.
skip_aggregator_types: List of aggregation types to skip sending as tts/output messages.
Note: if using this to avoid sending secure information, be sure to also disable
bot_llm_enabled to avoid leaking through LLM messages.
@@ -1065,6 +1071,7 @@ class RTVIObserverParams:
metrics_enabled: bool = True
system_logs_enabled: bool = False
errors_enabled: Optional[bool] = None
ignored_sources: List[FrameProcessor] = field(default_factory=list)
skip_aggregator_types: Optional[List[AggregationType | str]] = None
bot_output_transforms: Optional[
List[
@@ -1110,12 +1117,17 @@ class RTVIObserver(BaseObserver):
self._rtvi = rtvi
self._params = params or RTVIObserverParams()
self._ignored_sources: Set[FrameProcessor] = set(self._params.ignored_sources)
self._frames_seen = set()
self._bot_transcription = ""
self._last_user_audio_level = 0
self._last_bot_audio_level = 0
# Track bot speaking state for queuing aggregated text frames
self._bot_is_speaking = False
self._queued_aggregated_text_frames: List[AggregatedTextFrame] = []
if self._params.system_logs_enabled:
self._system_logger_id = logger.add(self._logger_sink)
@@ -1166,6 +1178,31 @@ class RTVIObserver(BaseObserver):
if not (agg_type == aggregation_type and func == transform_function)
]
def add_ignored_source(self, source: FrameProcessor):
"""Ignore all frames pushed by the given processor.
Any frame whose source matches ``source`` will be silently skipped,
preventing RTVI messages from being emitted for activity in that
processor. Useful for suppressing events from secondary pipeline
branches (e.g. a silent evaluation LLM) that should not be visible
to clients.
Args:
source: The frame processor to ignore.
"""
self._ignored_sources.add(source)
def remove_ignored_source(self, source: FrameProcessor):
"""Stop ignoring frames pushed by the given processor.
Reverses a previous call to ``add_ignored_source()``. If ``source``
was not previously ignored this is a no-op.
Args:
source: The frame processor to stop ignoring.
"""
self._ignored_sources.discard(source)
def _get_function_call_report_level(self, function_name: str) -> RTVIFunctionCallReportLevel:
"""Get the report level for a specific function call.
@@ -1216,10 +1253,13 @@ class RTVIObserver(BaseObserver):
frame = data.frame
direction = data.direction
# Only process downstream frames. Some frames are broadcast in both
# directions (e.g. UserStartedSpeakingFrame, FunctionCallResultFrame),
# and we only want to send one RTVI message per event.
if direction != FrameDirection.DOWNSTREAM:
# Frames from explicitly ignored sources are always skipped.
if self._ignored_sources and src in self._ignored_sources:
return
# For broadcast frames (pushed in both directions), only process
# the downstream copy to avoid sending duplicate RTVI messages.
if frame.broadcast_sibling_id is not None and direction != FrameDirection.DOWNSTREAM:
return
# If we have already seen this frame, let's skip it.
@@ -1384,17 +1424,30 @@ class RTVIObserver(BaseObserver):
async def _handle_bot_speaking(self, frame: Frame):
"""Handle bot speaking event frames."""
message = None
if isinstance(frame, BotStartedSpeakingFrame):
message = RTVIBotStartedSpeakingMessage()
await self.send_rtvi_message(message)
# Flush any queued aggregated text frames
for queued_frame in self._queued_aggregated_text_frames:
await self._send_aggregated_llm_text(queued_frame)
self._queued_aggregated_text_frames.clear()
self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
message = RTVIBotStoppedSpeakingMessage()
if message:
await self.send_rtvi_message(message)
self._bot_is_speaking = False
async def _handle_aggregated_llm_text(self, frame: AggregatedTextFrame):
"""Handle aggregated LLM text output frames."""
if self._bot_is_speaking:
# Bot has already started speaking, send directly
await self._send_aggregated_llm_text(frame)
else:
# Bot hasn't started speaking yet, queue the frame
self._queued_aggregated_text_frames.append(frame)
async def _send_aggregated_llm_text(self, frame: AggregatedTextFrame):
"""Send aggregated LLM text messages."""
# Skip certain aggregator types if configured to do so.
if (
self._params.skip_aggregator_types

View File

@@ -107,49 +107,70 @@ class FrameProcessorMetrics(BaseObject):
"""
self._core_metrics_data = MetricsData(processor=name)
async def start_ttfb_metrics(self, report_only_initial_ttfb):
async def start_ttfb_metrics(
self, *, start_time: Optional[float] = None, report_only_initial_ttfb: bool
):
"""Start measuring time-to-first-byte (TTFB).
Args:
start_time: Optional timestamp to use as the start time. If None,
uses the current time.
report_only_initial_ttfb: Whether to report only the first TTFB measurement.
"""
if self._should_report_ttfb:
self._start_ttfb_time = time.time()
self._start_ttfb_time = start_time or time.time()
self._last_ttfb_time = 0
self._should_report_ttfb = not report_only_initial_ttfb
async def stop_ttfb_metrics(self):
async def stop_ttfb_metrics(self, *, end_time: Optional[float] = None):
"""Stop TTFB measurement and generate metrics frame.
Args:
end_time: Optional timestamp to use as the end time. If None, uses
the current time.
Returns:
MetricsFrame containing TTFB data, or None if not measuring.
"""
if self._start_ttfb_time == 0:
return None
self._last_ttfb_time = time.time() - self._start_ttfb_time
logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time}")
end_time = end_time or time.time()
self._last_ttfb_time = end_time - self._start_ttfb_time
logger.debug(f"{self._processor_name()} TTFB: {self._last_ttfb_time:.3f}s")
ttfb = TTFBMetricsData(
processor=self._processor_name(), value=self._last_ttfb_time, model=self._model_name()
)
self._start_ttfb_time = 0
return MetricsFrame(data=[ttfb])
async def start_processing_metrics(self):
"""Start measuring processing time."""
self._start_processing_time = time.time()
async def start_processing_metrics(self, *, start_time: Optional[float] = None):
"""Start measuring processing time.
async def stop_processing_metrics(self):
Args:
start_time: Optional timestamp to use as the start time. If None,
uses the current time.
"""
self._start_processing_time = start_time or time.time()
async def stop_processing_metrics(self, *, end_time: Optional[float] = None):
"""Stop processing time measurement and generate metrics frame.
Args:
end_time: Optional timestamp to use as the end time. If None, uses
the current time.
Returns:
MetricsFrame containing processing duration data, or None if not measuring.
"""
if self._start_processing_time == 0:
return None
value = time.time() - self._start_processing_time
logger.debug(f"{self._processor_name()} processing time: {value}")
end_time = end_time or time.time()
value = end_time - self._start_processing_time
logger.debug(f"{self._processor_name()} processing time: {value:.3f}s")
processing = ProcessingMetricsData(
processor=self._processor_name(), value=value, model=self._model_name()
)

View File

@@ -44,6 +44,8 @@ class AIService(FrameProcessor):
super().__init__(**kwargs)
self._settings: ServiceSettings = ServiceSettings(model="")
self._session_properties: Dict[str, Any] = {}
self._tracing_enabled: bool = False
self._tracing_context = None
def _sync_model_name_to_metrics(self):
"""Sync the current AI model name (in `self._settings.model`) for usage in metrics.
@@ -71,7 +73,8 @@ class AIService(FrameProcessor):
Args:
frame: The start frame containing initialization parameters.
"""
pass
self._tracing_enabled = frame.enable_tracing
self._tracing_context = frame.tracing_context
async def stop(self, frame: EndFrame):
"""Stop the AI service.

View File

@@ -214,7 +214,7 @@ class AnthropicLLMService(LLMService):
self,
*,
api_key: str,
model: str = "claude-sonnet-4-5-20250929",
model: str = "claude-sonnet-4-6",
params: Optional[InputParams] = None,
client=None,
retry_timeout_secs: Optional[float] = 5.0,
@@ -225,7 +225,7 @@ class AnthropicLLMService(LLMService):
Args:
api_key: Anthropic API key for authentication.
model: Model name to use. Defaults to "claude-sonnet-4-5-20250929".
model: Model name to use. Defaults to "claude-sonnet-4-6".
params: Optional model parameters for inference.
client: Optional custom Anthropic client instance.
retry_timeout_secs: Request timeout in seconds for retry logic.

View File

@@ -175,7 +175,6 @@ class AsyncAITTSService(AudioContextTTSService):
self._receive_task = None
self._keepalive_task = None
self._context_id = None
async def _update_settings(self, update: TTSSettings) -> dict[str, Any]:
"""Apply a settings update.
@@ -275,7 +274,7 @@ class AsyncAITTSService(AudioContextTTSService):
f"{self._url}?api_key={self._api_key}&version={self._api_version}"
)
init_msg = {
"model_id": self._model_name,
"model_id": self._settings.model,
"voice": {"mode": "id", "id": self._settings.voice},
"output_format": {
"container": self._settings.output_container,
@@ -300,7 +299,7 @@ class AsyncAITTSService(AudioContextTTSService):
if self._websocket:
logger.debug("Disconnecting from Async")
# Close all contexts and the socket
if self._context_id:
if self.has_active_audio_context():
await self._websocket.send(json.dumps({"terminate": True}))
await self._websocket.close()
logger.debug("Disconnected from Async")
@@ -308,7 +307,7 @@ class AsyncAITTSService(AudioContextTTSService):
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
self._websocket = None
self._context_id = None
await self.remove_active_audio_context()
await self._call_event_handler("on_disconnected")
def _get_websocket(self):
@@ -318,10 +317,11 @@ class AsyncAITTSService(AudioContextTTSService):
async def flush_audio(self):
"""Flush any pending audio."""
if not self._context_id or not self._websocket:
context_id = self.get_active_audio_context_id()
if not context_id or not self._websocket:
return
logger.trace(f"{self}: flushing audio")
msg = self._build_msg(text=" ", context_id=self._context_id, force=True)
msg = self._build_msg(text=" ", context_id=context_id, force=True)
await self._websocket.send(msg)
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
@@ -349,11 +349,11 @@ class AsyncAITTSService(AudioContextTTSService):
# Check if this message belongs to the current context.
if not self.audio_context_available(received_ctx_id):
if self._context_id == received_ctx_id:
if self.get_active_audio_context_id() == received_ctx_id:
logger.debug(
f"Received a delayed message, recreating the context: {self._context_id}"
f"Received a delayed message, recreating the context: {received_ctx_id}"
)
await self.create_audio_context(self._context_id)
await self.create_audio_context(received_ctx_id)
else:
# This can happen if a message is received _after_ we have closed a context
# due to user interruption but _before_ the `isFinal` message for the context
@@ -374,10 +374,11 @@ class AsyncAITTSService(AudioContextTTSService):
await asyncio.sleep(KEEPALIVE_SLEEP)
try:
if self._websocket and self._websocket.state is State.OPEN:
if self._context_id:
context_id = self.get_active_audio_context_id()
if context_id:
keepalive_message = {
"transcript": " ",
"context_id": self._context_id,
"context_id": context_id,
}
logger.trace("Sending keepalive message")
else:
@@ -393,19 +394,16 @@ class AsyncAITTSService(AudioContextTTSService):
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by closing the current context."""
context_id = self.get_active_audio_context_id()
await super()._handle_interruption(frame, direction)
# Close the current context when interrupted without closing the websocket
if self._context_id and self._websocket:
if context_id and self._websocket:
try:
await self._websocket.send(
json.dumps(
{"context_id": self._context_id, "close_context": True, "transcript": ""}
)
json.dumps({"context_id": context_id, "close_context": True, "transcript": ""})
)
except Exception as e:
logger.error(f"Error closing context on interruption: {e}")
self._context_id = None
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
@@ -425,15 +423,13 @@ class AsyncAITTSService(AudioContextTTSService):
await self._connect()
try:
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
if not self.audio_context_available(context_id):
await self.create_audio_context(context_id)
if not self._context_id:
self._context_id = context_id
if not self.audio_context_available(self._context_id):
await self.create_audio_context(self._context_id)
msg = self._build_msg(text=text, force=True, context_id=self._context_id)
msg = self._build_msg(text=text, force=True, context_id=context_id)
await self._get_websocket().send(msg)
await self.start_tts_usage_metrics(text)

View File

@@ -345,7 +345,6 @@ class CartesiaTTSService(AudioContextWordTTSService):
)
self._sync_model_name_to_metrics()
self._context_id = None
self._receive_task = None
def can_generate_metrics(self) -> bool:
@@ -457,7 +456,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
msg = {
"transcript": text,
"continue": continue_transcript,
"context_id": self._context_id,
"context_id": self.get_active_audio_context_id(),
"model_id": self._settings.model,
"voice": voice_config,
"output_format": {
@@ -554,7 +553,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
self._context_id = None
await self.remove_active_audio_context()
self._websocket = None
await self._call_event_handler("on_disconnected")
@@ -564,21 +563,22 @@ class CartesiaTTSService(AudioContextWordTTSService):
raise Exception("Websocket not connected")
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
context_id = self.get_active_audio_context_id()
await super()._handle_interruption(frame, direction)
await self.stop_all_metrics()
if self._context_id:
cancel_msg = json.dumps({"context_id": self._context_id, "cancel": True})
if context_id:
cancel_msg = json.dumps({"context_id": context_id, "cancel": True})
await self._get_websocket().send(cancel_msg)
self._context_id = None
async def flush_audio(self):
"""Flush any pending audio and finalize the current context."""
if not self._context_id or not self._websocket:
context_id = self.get_active_audio_context_id()
if not context_id or not self._websocket:
return
logger.trace(f"{self}: flushing audio")
msg = self._build_msg(text="", continue_transcript=False)
await self._websocket.send(msg)
self._context_id = None
self.reset_active_audio_context()
async def _process_messages(self):
async for message in self._get_websocket():
@@ -610,7 +610,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
await self.push_frame(TTSStoppedFrame(context_id=ctx_id))
await self.stop_all_metrics()
await self.push_error(error_msg=f"Error: {msg}")
self._context_id = None
self.reset_active_audio_context()
else:
await self.push_error(error_msg=f"Error, unknown message type: {msg}")
@@ -639,11 +639,10 @@ class CartesiaTTSService(AudioContextWordTTSService):
if not self._websocket or self._websocket.state is State.CLOSED:
await self._connect()
if not self._context_id:
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._context_id = context_id
await self.create_audio_context(self._context_id)
await self.create_audio_context(context_id)
msg = self._build_msg(text=text)

View File

@@ -103,6 +103,20 @@ class DeepgramFluxSTTService(WebsocketSTTService):
Provides real-time speech recognition using Deepgram's WebSocket API with Flux capabilities.
Supports configurable models, VAD events, and various audio processing options
including advanced turn detection and EagerEndOfTurn events for improved conversational AI performance.
Event handlers available (in addition to WebsocketSTTService events):
- on_speech_started(service): Deepgram detected start of speech
- on_utterance_end(service): Deepgram detected end of utterance
- on_end_of_turn(service): Deepgram detected end of turn (EOT)
- on_eager_end_of_turn(service): Deepgram predicted end of turn (EagerEOT)
- on_turn_resumed(service): User resumed speaking after EagerEOT
Example::
@stt.event_handler("on_end_of_turn")
async def on_end_of_turn(service):
...
"""
_settings: DeepgramFluxSTTSettings

View File

@@ -63,6 +63,17 @@ class DeepgramSTTService(STTService):
Provides real-time speech recognition using Deepgram's WebSocket API.
Supports configurable models, languages, and various audio processing options.
Event handlers available (in addition to STTService events):
- on_speech_started(service): Deepgram detected start of speech
- on_utterance_end(service): Deepgram detected end of utterance
Example::
@stt.event_handler("on_speech_started")
async def on_speech_started(service):
...
"""
_settings: DeepgramSTTSettings

View File

@@ -403,7 +403,6 @@ class DeepgramSageMakerSTTService(STTService):
return
is_final = parsed.get("is_final", False)
speech_final = parsed.get("speech_final", False)
# Extract language if available
language = None
@@ -411,8 +410,12 @@ class DeepgramSageMakerSTTService(STTService):
language = alternatives[0]["languages"][0]
language = Language(language)
if is_final and speech_final:
# Final transcription
if is_final:
# Check if this response is from a finalize() call.
# Only mark as finalized when both we requested it AND Deepgram confirms it.
from_finalize = parsed.get("from_finalize", False)
if from_finalize:
self.confirm_finalize()
await self.push_frame(
TranscriptionFrame(
transcript,
@@ -470,10 +473,12 @@ class DeepgramSageMakerSTTService(STTService):
if isinstance(frame, VADUserStartedSpeakingFrame):
await self._start_metrics()
elif isinstance(frame, VADUserStoppedSpeakingFrame):
# Send finalize message to Deepgram when user stops speaking
# This tells Deepgram to flush any remaining audio and return final results
# https://developers.deepgram.com/docs/finalize
# Mark that we're awaiting a from_finalize response
self.request_finalize()
if self._client and self._client.is_active:
try:
await self._client.send_json({"type": "Finalize"})
except Exception as e:
logger.warning(f"Error sending Finalize message: {e}")
logger.trace(f"Triggered finalize event on: {frame.name=}, {direction=}")

View File

@@ -111,6 +111,7 @@ class DeepgramTTSService(WebsocketTTSService):
voice=voice,
encoding=encoding,
)
self._sync_model_name_to_metrics()
self._receive_task = None
self._context_id: Optional[str] = None
@@ -193,6 +194,11 @@ class DeepgramTTSService(WebsocketTTSService):
"""
changed = await super()._update_settings(update)
# Deepgram uses voice as the model, so keep them in sync for metrics
if "voice" in changed:
self._settings.model = self._settings.voice
self._sync_model_name_to_metrics()
if changed:
await self._disconnect()
await self._connect()
@@ -397,6 +403,7 @@ class DeepgramHttpTTSService(TTSService):
voice=voice,
encoding=encoding,
)
self._sync_model_name_to_metrics()
def can_generate_metrics(self) -> bool:
"""Check if the service can generate metrics.

View File

@@ -0,0 +1,354 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Deepgram text-to-speech service for AWS SageMaker.
This module provides a Pipecat TTS service that connects to Deepgram models
deployed on AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for
low-latency real-time speech synthesis with support for interruptions and
streaming audio output.
"""
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, Optional
from loguru import logger
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
ErrorFrame,
Frame,
InterruptionFrame,
LLMFullResponseEndFrame,
StartFrame,
TTSAudioRawFrame,
TTSStartedFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven
from pipecat.services.tts_service import TTSService
from pipecat.utils.tracing.service_decorators import traced_tts
@dataclass
class DeepgramSageMakerTTSSettings(TTSSettings):
"""Settings for Deepgram SageMaker TTS service.
Parameters:
encoding: Audio encoding format (e.g. "linear16").
"""
encoding: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class DeepgramSageMakerTTSService(TTSService):
"""Deepgram text-to-speech service for AWS SageMaker.
Provides real-time speech synthesis using Deepgram models deployed on
AWS SageMaker endpoints. Uses HTTP/2 bidirectional streaming for low-latency
audio generation with support for interruptions via the Clear message.
Requirements:
- AWS credentials configured (via environment variables, AWS CLI, or instance metadata)
- A deployed SageMaker endpoint with Deepgram TTS model: https://developers.deepgram.com/docs/deploy-amazon-sagemaker
- ``pipecat-ai[sagemaker]`` installed
Example::
tts = DeepgramSageMakerTTSService(
endpoint_name="my-deepgram-tts-endpoint",
region="us-east-2",
voice="aura-2-helena-en",
)
"""
_settings: DeepgramSageMakerTTSSettings
def __init__(
self,
*,
endpoint_name: str,
region: str,
voice: str = "aura-2-helena-en",
sample_rate: Optional[int] = None,
encoding: str = "linear16",
**kwargs,
):
"""Initialize the Deepgram SageMaker TTS service.
Args:
endpoint_name: Name of the SageMaker endpoint with Deepgram TTS model
deployed (e.g., "my-deepgram-tts-endpoint").
region: AWS region where the endpoint is deployed (e.g., "us-east-2").
voice: Voice model to use for synthesis. Defaults to "aura-2-helena-en".
sample_rate: Audio sample rate in Hz. If None, uses the value from StartFrame.
encoding: Audio encoding format. Defaults to "linear16".
**kwargs: Additional arguments passed to the parent TTSService.
"""
super().__init__(
sample_rate=sample_rate,
push_stop_frames=True,
pause_frame_processing=True,
append_trailing_space=True,
**kwargs,
)
self._endpoint_name = endpoint_name
self._region = region
self._settings = DeepgramSageMakerTTSSettings(
model=voice,
voice=voice,
encoding=encoding,
)
self._sync_model_name_to_metrics()
self._client: Optional[SageMakerBidiClient] = None
self._response_task: Optional[asyncio.Task] = None
self._context_id: Optional[str] = None
self._ttfb_started: bool = False
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
Returns:
True, as Deepgram SageMaker TTS service supports metrics generation.
"""
return True
async def start(self, frame: StartFrame):
"""Start the Deepgram SageMaker TTS service.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
await self._connect()
async def stop(self, frame: EndFrame):
"""Stop the Deepgram SageMaker TTS service.
Args:
frame: The end frame.
"""
await super().stop(frame)
await self._disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the Deepgram SageMaker TTS service.
Args:
frame: The cancel frame.
"""
await super().cancel(frame)
await self._disconnect()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames with special handling for LLM response end.
Args:
frame: The frame to process.
direction: The direction of frame processing.
"""
await super().process_frame(frame, direction)
if isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
await self.flush_audio()
elif isinstance(frame, BotStoppedSpeakingFrame):
self._ttfb_started = False
async def _connect(self):
"""Connect to the SageMaker endpoint and start the BiDi session.
Builds the Deepgram TTS query string, creates the BiDi client,
starts the streaming session, and launches a background task for processing
responses.
"""
logger.debug("Connecting to Deepgram TTS on SageMaker...")
query_string = (
f"model={self._settings.voice}&encoding={self._settings.encoding}"
f"&sample_rate={self.sample_rate}"
)
self._client = SageMakerBidiClient(
endpoint_name=self._endpoint_name,
region=self._region,
model_invocation_path="v1/speak",
model_query_string=query_string,
)
try:
await self._client.start_session()
self._response_task = self.create_task(self._process_responses())
logger.debug("Connected to Deepgram TTS on SageMaker")
await self._call_event_handler("on_connected")
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
await self._call_event_handler("on_connection_error", str(e))
async def _disconnect(self):
"""Disconnect from the SageMaker endpoint.
Sends a Close message to Deepgram, cancels the response processing task,
and closes the BiDi session. Safe to call multiple times.
"""
if self._client and self._client.is_active:
logger.debug("Disconnecting from Deepgram TTS on SageMaker...")
try:
await self._client.send_json({"type": "Close"})
except Exception as e:
logger.warning(f"Failed to send Close message: {e}")
if self._response_task and not self._response_task.done():
await self.cancel_task(self._response_task)
await self._client.close_session()
logger.debug("Disconnected from Deepgram TTS on SageMaker")
await self._call_event_handler("on_disconnected")
async def _update_settings(self, update: TTSSettings) -> dict[str, Any]:
"""Apply a settings update and reconnect if necessary.
Since all settings are part of the SageMaker session query string,
any setting change requires reconnecting to apply the new values.
"""
changed = await super()._update_settings(update)
# Deepgram uses voice as the model, so keep them in sync for metrics
if "voice" in changed:
self._settings.model = self._settings.voice
self._sync_model_name_to_metrics()
if changed:
await self._disconnect()
await self._connect()
return changed
async def _process_responses(self):
"""Process streaming responses from Deepgram TTS on SageMaker.
Continuously receives responses from the BiDi stream. Attempts to decode
each payload as UTF-8 JSON for control messages (Flushed, Cleared, Metadata,
Warning). If decoding fails, treats the payload as raw audio bytes and pushes
a TTSAudioRawFrame downstream.
"""
try:
while self._client and self._client.is_active:
result = await self._client.receive_response()
if result is None:
break
if hasattr(result, "value") and hasattr(result.value, "bytes_"):
if result.value.bytes_:
payload = result.value.bytes_
# Try to decode as JSON control message first
try:
response_data = payload.decode("utf-8")
parsed = json.loads(response_data)
msg_type = parsed.get("type")
if msg_type == "Metadata":
logger.trace(f"Received metadata: {parsed}")
elif msg_type == "Flushed":
logger.trace(f"Received Flushed: {parsed}")
elif msg_type == "Cleared":
logger.trace(f"Received Cleared: {parsed}")
elif msg_type == "Warning":
logger.warning(
f"{self} warning: "
f"{parsed.get('description', 'Unknown warning')}"
)
else:
logger.debug(f"Received unknown message type: {parsed}")
except (UnicodeDecodeError, json.JSONDecodeError):
# Not JSON — treat as raw audio bytes
await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(
payload,
self.sample_rate,
1,
context_id=self._context_id,
)
await self.push_frame(frame)
except asyncio.CancelledError:
logger.debug("TTS response processor cancelled")
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
logger.debug("TTS response processor stopped")
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by sending Clear message to Deepgram.
The Clear message will clear Deepgram's internal text buffer and stop
sending audio, allowing for a new response to be generated.
"""
await super()._handle_interruption(frame, direction)
self._ttfb_started = False
if self._client and self._client.is_active:
try:
await self._client.send_json({"type": "Clear"})
except Exception as e:
logger.error(f"{self} error sending Clear message: {e}")
async def flush_audio(self):
"""Flush any pending audio synthesis by sending Flush command.
This should be called when the LLM finishes a complete response to force
generation of audio from Deepgram's internal text buffer.
"""
if self._client and self._client.is_active:
try:
await self._client.send_json({"type": "Flush"})
except Exception as e:
logger.error(f"{self} error sending Flush message: {e}")
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text using Deepgram TTS on SageMaker.
Args:
text: The text to synthesize into speech.
context_id: The context ID for tracking audio frames.
Yields:
Frame: TTSStartedFrame, then None (audio comes asynchronously via
the response processor).
"""
logger.debug(f"{self}: Generating TTS [{text}]")
try:
if not self._ttfb_started:
await self.start_ttfb_metrics()
self._ttfb_started = True
await self.start_tts_usage_metrics(text)
yield TTSStartedFrame(context_id=context_id)
self._context_id = context_id
await self._client.send_json({"type": "Speak", "text": text})
yield None
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")

View File

@@ -435,7 +435,6 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
self._partial_word_start_time = 0.0
# Context management for v1 multi API
self._context_id = None
self._receive_task = None
self._keepalive_task = None
@@ -505,19 +504,20 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
)
await self._disconnect()
await self._connect()
elif voice_settings_changed and self._context_id:
elif voice_settings_changed and self.has_active_audio_context():
logger.debug(
f"Voice settings changed ({changed.keys() & ElevenLabsTTSSettings.VOICE_SETTINGS_FIELDS}), "
f"closing current context to apply changes"
)
context_id = self.get_active_audio_context_id()
try:
if self._websocket:
await self._websocket.send(
json.dumps({"context_id": self._context_id, "close_context": True})
json.dumps({"context_id": context_id, "close_context": True})
)
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
self._context_id = None
self.reset_active_audio_context()
if not url_changed:
# Reconnect applies all settings; only warn about fields not handled
@@ -557,10 +557,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
async def flush_audio(self):
"""Flush any pending audio and finalize the current context."""
if not self._context_id or not self._websocket:
context_id = self.get_active_audio_context_id()
if not context_id or not self._websocket:
return
logger.trace(f"{self}: flushing audio")
msg = {"context_id": self._context_id, "flush": True}
msg = {"context_id": context_id, "flush": True}
await self._websocket.send(json.dumps(msg))
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
@@ -573,7 +574,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
await super().push_frame(frame, direction)
if isinstance(frame, (TTSStoppedFrame, InterruptionFrame)):
if isinstance(frame, TTSStoppedFrame):
await self.add_word_timestamps([("Reset", 0)], self._context_id)
await self.add_word_timestamps([("Reset", 0)], self.get_active_audio_context_id())
async def _connect(self):
await super()._connect()
@@ -648,14 +649,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
if self._websocket:
logger.debug("Disconnecting from ElevenLabs")
# Close all contexts and the socket
if self._context_id:
if self.has_active_audio_context():
await self._websocket.send(json.dumps({"close_socket": True}))
await self._websocket.close()
logger.debug("Disconnected from ElevenLabs")
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
self._context_id = None
await self.remove_active_audio_context()
self._websocket = None
await self._call_event_handler("on_disconnected")
@@ -666,11 +667,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by closing the current context."""
# Close the current context when interrupted without closing the websocket
context_id = self.get_active_audio_context_id()
await super()._handle_interruption(frame, direction)
# Close the current context when interrupted without closing the websocket
if self._context_id and self._websocket:
logger.trace(f"Closing context {self._context_id} due to interruption")
if context_id and self._websocket:
logger.trace(f"Closing context {context_id} due to interruption")
try:
# ElevenLabs requires that Pipecat manages the contexts and closes them
# when they're not longer in use. Since an InterruptionFrame is pushed
@@ -679,11 +681,10 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
# Note: We do not need to call remove_audio_context here, as the context is
# automatically reset when super ()._handle_interruption is called.
await self._websocket.send(
json.dumps({"context_id": self._context_id, "close_context": True})
json.dumps({"context_id": context_id, "close_context": True})
)
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
self._context_id = None
self._partial_word = ""
self._partial_word_start_time = 0.0
@@ -703,11 +704,11 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
# Check if this message belongs to the current context.
if not self.audio_context_available(received_ctx_id):
if self._context_id == received_ctx_id:
if self.get_active_audio_context_id() == received_ctx_id:
logger.debug(
f"Received a delayed message, recreating the context: {self._context_id}"
f"Received a delayed message, recreating the context: {received_ctx_id}"
)
await self.create_audio_context(self._context_id)
await self.create_audio_context(received_ctx_id)
else:
# This can happen if a message is received _after_ we have closed a context
# due to user interruption but _before_ the `isFinal` message for the context
@@ -760,13 +761,14 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
await asyncio.sleep(KEEPALIVE_SLEEP)
try:
if self._websocket and self._websocket.state is State.OPEN:
if self._context_id:
context_id = self.get_active_audio_context_id()
if context_id:
# Send keepalive with context ID to keep the connection alive
keepalive_message = {
"text": "",
"context_id": self._context_id,
"context_id": context_id,
}
logger.trace(f"Sending keepalive for context {self._context_id}")
logger.trace(f"Sending keepalive for context {context_id}")
else:
# It's possible to have a user interruption which clears the context
# without generating a new TTS response. In this case, we'll just send
@@ -780,8 +782,9 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
async def _send_text(self, text: str):
"""Send text to the WebSocket for synthesis."""
if self._websocket and self._context_id:
msg = {"text": text, "context_id": self._context_id}
context_id = self.get_active_audio_context_id()
if self._websocket and context_id:
msg = {"text": text, "context_id": context_id}
await self._websocket.send(json.dumps(msg))
@traced_tts
@@ -802,31 +805,27 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
await self._connect()
try:
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._cumulative_time = 0
self._partial_word = ""
self._partial_word_start_time = 0.0
# If a context ID does not exist, use the provided one.
# If an ID exists, that means the Pipeline doesn't allow
# user interruptions, so continue using the current ID.
# When interruptions are allowed, user speech results in
# an interruption, which resets the context ID.
if not self._context_id:
self._context_id = context_id
if not self.audio_context_available(self._context_id):
await self.create_audio_context(self._context_id)
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._cumulative_time = 0
self._partial_word = ""
self._partial_word_start_time = 0.0
# Initialize context with voice settings and pronunciation dictionaries
msg = {"text": " ", "context_id": self._context_id}
if self._voice_settings:
msg["voice_settings"] = self._voice_settings
if self._pronunciation_dictionary_locators:
msg["pronunciation_dictionary_locators"] = [
locator.model_dump() for locator in self._pronunciation_dictionary_locators
]
await self._websocket.send(json.dumps(msg))
logger.trace(f"Created new context {self._context_id}")
if not self.audio_context_available(context_id):
await self.create_audio_context(context_id)
# Initialize context with voice settings and pronunciation dictionaries
msg = {"text": " ", "context_id": context_id}
if self._voice_settings:
msg["voice_settings"] = self._voice_settings
if self._pronunciation_dictionary_locators:
msg["pronunciation_dictionary_locators"] = [
locator.model_dump()
for locator in self._pronunciation_dictionary_locators
]
await self._websocket.send(json.dumps(msg))
logger.trace(f"Created new context {context_id}")
await self._send_text(text)
await self.start_tts_usage_metrics(text)

View File

@@ -17,6 +17,7 @@ from pipecat.frames.frames import (
EndFrame,
ErrorFrame,
Frame,
InterruptionFrame,
StartFrame,
TTSAudioRawFrame,
TTSStartedFrame,
@@ -24,7 +25,7 @@ from pipecat.frames.frames import (
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven
from pipecat.services.tts_service import InterruptibleWordTTSService
from pipecat.services.tts_service import AudioContextWordTTSService
from pipecat.utils.tracing.service_decorators import traced_tts
try:
@@ -50,7 +51,7 @@ class GradiumTTSSettings(TTSSettings):
output_format: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class GradiumTTSService(InterruptibleWordTTSService):
class GradiumTTSService(AudioContextWordTTSService):
"""Text-to-Speech service using Gradium's websocket API."""
_settings: GradiumTTSSettings
@@ -86,9 +87,9 @@ class GradiumTTSService(InterruptibleWordTTSService):
params: Additional configuration parameters.
**kwargs: Additional arguments passed to parent class.
"""
# Initialize with parent class settings for proper frame handling
super().__init__(
push_stop_frames=True,
push_text_frames=False,
pause_frame_processing=True,
sample_rate=SAMPLE_RATE,
**kwargs,
@@ -108,7 +109,6 @@ class GradiumTTSService(InterruptibleWordTTSService):
# State tracking
self._receive_task = None
self._current_context_id: Optional[str] = None
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
@@ -137,7 +137,11 @@ class GradiumTTSService(InterruptibleWordTTSService):
def _build_msg(self, text: str = "") -> dict:
"""Build JSON message for Gradium API."""
return {"text": text, "type": "text"}
msg = {"text": text, "type": "text"}
context_id = self.get_active_audio_context_id()
if context_id:
msg["client_req_id"] = context_id
return msg
async def start(self, frame: StartFrame):
"""Start the service and establish websocket connection.
@@ -208,6 +212,7 @@ class GradiumTTSService(InterruptibleWordTTSService):
"type": "setup",
"output_format": "pcm",
"voice_id": self._settings.voice,
"close_ws_on_eos": False,
}
if self._json_config is not None:
setup_msg["json_config"] = self._json_config
@@ -234,6 +239,7 @@ class GradiumTTSService(InterruptibleWordTTSService):
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
await self.remove_active_audio_context()
self._websocket = None
await self._call_event_handler("on_disconnected")
@@ -245,18 +251,35 @@ class GradiumTTSService(InterruptibleWordTTSService):
async def flush_audio(self):
"""Flush any pending audio synthesis."""
if not self._websocket:
context_id = self.get_active_audio_context_id()
if not context_id or not self._websocket:
return
try:
msg = {"type": "end_of_stream"}
msg = {"type": "end_of_stream", "client_req_id": context_id}
await self._websocket.send(json.dumps(msg))
self.reset_active_audio_context()
except ConnectionClosedOK:
logger.debug(f"{self}: connection closed normally during flush")
except Exception as e:
logger.error(f"{self} exception: {e}")
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by resetting context state.
The parent AudioContextTTSService._handle_interruption() cancels the audio context
task and creates a new one. We reset _context_id so the next run_tts() creates a
fresh context. No websocket reconnection needed — audio from the old client_req_id
will be silently dropped since the audio context no longer exists.
Args:
frame: The interruption frame.
direction: The direction of the frame.
"""
await super()._handle_interruption(frame, direction)
await self.stop_all_metrics()
async def _receive_messages(self):
"""Process incoming websocket messages."""
"""Process incoming websocket messages, demultiplexing by client_req_id."""
# TODO(laurent): This should not be necessary as it should happen when
# receiving the messages but this does not seem to always be the case
# and that may lead to a busy polling loop.
@@ -264,41 +287,35 @@ class GradiumTTSService(InterruptibleWordTTSService):
raise ConnectionClosedOK(None, None)
async for message in self._get_websocket():
msg = json.loads(message)
ctx_id = msg.get("client_req_id")
if msg["type"] == "audio":
# Process audio chunk
if not ctx_id or not self.audio_context_available(ctx_id):
continue
await self.stop_ttfb_metrics()
await self.start_word_timestamps()
frame = TTSAudioRawFrame(
audio=base64.b64decode(msg["audio"]),
sample_rate=self.sample_rate,
num_channels=1,
context_id=self._current_context_id,
context_id=ctx_id,
)
await self.push_frame(frame)
await self.append_to_audio_context(ctx_id, frame)
elif msg["type"] == "text":
if self._current_context_id:
await self.add_word_timestamps(
[(msg["text"], msg["start_s"])], self._current_context_id
)
if ctx_id and self.audio_context_available(ctx_id):
await self.add_word_timestamps([(msg["text"], msg["start_s"])], ctx_id)
elif msg["type"] == "end_of_stream":
await self.push_frame(TTSStoppedFrame())
if ctx_id and self.audio_context_available(ctx_id):
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
await self.remove_audio_context(ctx_id)
await self.stop_all_metrics()
elif msg["type"] == "error":
await self.push_frame(TTSStoppedFrame())
await self.push_frame(TTSStoppedFrame(context_id=ctx_id))
await self.stop_all_metrics()
await self.push_error(error_msg=f"Error: {msg['message']}")
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
"""Push frame and handle end-of-turn conditions.
Args:
frame: The frame to push.
direction: The direction to push the frame.
"""
await super().push_frame(frame, direction)
await self.push_error(error_msg=f"Error: {msg.get('message', msg)}")
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
@@ -311,16 +328,17 @@ class GradiumTTSService(InterruptibleWordTTSService):
Yields:
Frame: Audio frames containing the synthesized speech.
"""
_state = self._websocket.state if self._websocket is not None else None
logger.debug(f"{self}: Generating TTS [{text}] {_state}")
logger.debug(f"{self}: Generating TTS [{text}]")
try:
if not self._websocket or self._websocket.state is State.CLOSED:
self._websocket = None
await self._connect()
try:
self._current_context_id = context_id
yield TTSStartedFrame(context_id=context_id)
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
await self.create_audio_context(context_id)
msg = self._build_msg(text=text)
await self._get_websocket().send(json.dumps(msg))

View File

@@ -216,7 +216,7 @@ class SessionProperties(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
instructions: Optional[str] = None
voice: Optional[GrokVoice] = "Ara"
voice: Optional[GrokVoice | str] = "Ara"
turn_detection: Optional[TurnDetection] = Field(
default_factory=lambda: TurnDetection(type="server_vad")
)

View File

@@ -16,12 +16,17 @@ Inworlds text-to-speech (TTS) models offer ultra-realistic, context-aware spe
import asyncio
import base64
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, ClassVar, Dict, List, Mapping, Optional, Tuple
from typing import Any, AsyncGenerator, ClassVar, Dict, List, Literal, Mapping, Optional, Tuple
import aiohttp
import websockets
from loguru import logger
from pipecat import version as pipecat_version
USER_AGENT = f"pipecat/{pipecat_version()}"
from pydantic import BaseModel
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven, is_given
@@ -65,6 +70,7 @@ class InworldTTSSettings(TTSSettings):
maintaining high quality audio output. If None (default),
automatically set based on aggregate_sentences.
apply_text_normalization: Whether to apply text normalization.
timestamp_transport_strategy: Strategy for timestamp transport ("ASYNC" or "SYNC").
"""
audio_encoding: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
@@ -73,6 +79,7 @@ class InworldTTSSettings(TTSSettings):
temperature: float | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
auto_mode: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
apply_text_normalization: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
timestamp_transport_strategy: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
_aliases: ClassVar[Dict[str, str]] = {
"voice_id": "voice",
@@ -80,6 +87,7 @@ class InworldTTSSettings(TTSSettings):
"modelId": "model",
"applyTextNormalization": "apply_text_normalization",
"autoMode": "auto_mode",
"timestampTransportStrategy": "timestamp_transport_strategy",
}
@classmethod
@@ -109,10 +117,12 @@ class InworldHttpTTSService(WordTTSService):
Parameters:
temperature: Temperature for speech synthesis.
speaking_rate: Speaking rate for speech synthesis.
timestamp_transport_strategy: The strategy to use for timestamp transport.
"""
temperature: Optional[float] = None
speaking_rate: Optional[float] = None
timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC"
def __init__(
self,
@@ -170,6 +180,8 @@ class InworldHttpTTSService(WordTTSService):
self._settings.temperature = params.temperature
if params.speaking_rate is not None:
self._settings.speaking_rate = params.speaking_rate
if params.timestamp_transport_strategy is not None:
self._settings.timestamp_transport_strategy = params.timestamp_transport_strategy
self._cumulative_time = 0.0
@@ -288,10 +300,15 @@ class InworldHttpTTSService(WordTTSService):
# Use WORD timestamps for simplicity and correct spacing/capitalization
payload["timestampType"] = self._timestamp_type
if is_given(self._settings.timestamp_transport_strategy):
payload["timestampTransportStrategy"] = self._settings.timestamp_transport_strategy
request_id = str(uuid.uuid4())
headers = {
"Authorization": f"Basic {self._api_key}",
"Content-Type": "application/json",
"X-User-Agent": USER_AGENT,
"X-Request-Id": request_id,
}
try:
@@ -305,7 +322,7 @@ class InworldHttpTTSService(WordTTSService):
) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"Inworld API error: {error_text}")
logger.error(f"Inworld API error (request_id={request_id}): {error_text}")
yield ErrorFrame(error=f"Inworld API error: {error_text}")
return
@@ -474,6 +491,7 @@ class InworldTTSService(AudioContextWordTTSService):
flushing of buffered text to achieve minimal latency while
maintaining high quality audio output. If None (default),
automatically set based on aggregate_sentences.
timestamp_transport_strategy: The strategy to use for timestamp transport.
"""
temperature: Optional[float] = None
@@ -481,7 +499,8 @@ class InworldTTSService(AudioContextWordTTSService):
apply_text_normalization: Optional[str] = None
max_buffer_delay_ms: Optional[int] = None
buffer_char_threshold: Optional[int] = None
auto_mode: Optional[bool] = None
auto_mode: Optional[bool] = True
timestamp_transport_strategy: Optional[Literal["ASYNC", "SYNC"]] = "ASYNC"
def __init__(
self,
@@ -539,6 +558,8 @@ class InworldTTSService(AudioContextWordTTSService):
self._settings.speaking_rate = params.speaking_rate
if params.apply_text_normalization is not None:
self._settings.apply_text_normalization = params.apply_text_normalization
if params.timestamp_transport_strategy is not None:
self._settings.timestamp_transport_strategy = params.timestamp_transport_strategy
if params.auto_mode is not None:
self._settings.auto_mode = params.auto_mode
@@ -552,7 +573,6 @@ class InworldTTSService(AudioContextWordTTSService):
self._receive_task = None
self._keepalive_task = None
self._context_id = None
# Track cumulative time across generations for monotonic timestamps within a turn.
# When auto_mode is enabled, the server controls generations and timestamps reset
@@ -607,9 +627,10 @@ class InworldTTSService(AudioContextWordTTSService):
keeping the context open for subsequent text. The context is only
closed on interruption, disconnect, or end of session.
"""
if self._context_id and self._websocket:
logger.trace(f"Flushing audio for context {self._context_id}")
await self._send_flush(self._context_id)
context_id = self.get_active_audio_context_id()
if context_id and self._websocket:
logger.trace(f"Flushing audio for context {context_id}")
await self._send_flush(context_id)
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
"""Push a frame and handle state changes.
@@ -674,7 +695,7 @@ class InworldTTSService(AudioContextWordTTSService):
frame: The interruption frame.
direction: The direction of the interruption.
"""
old_context_id = self._context_id
old_context_id = self.get_active_audio_context_id()
logger.trace(f"{self}: Handling interruption, old context: {old_context_id}")
await super()._handle_interruption(frame, direction)
@@ -686,7 +707,6 @@ class InworldTTSService(AudioContextWordTTSService):
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
self._context_id = None
self._cumulative_time = 0.0
self._generation_end_time = 0.0
logger.trace(f"{self}: Interruption handled, context reset to None")
@@ -760,8 +780,13 @@ class InworldTTSService(AudioContextWordTTSService):
if self._websocket and self._websocket.state is State.OPEN:
return
logger.debug("Connecting to Inworld WebSocket TTS")
headers = [("Authorization", f"Basic {self._api_key}")]
request_id = str(uuid.uuid4())
logger.debug(f"Connecting to Inworld WebSocket TTS (request_id={request_id})")
headers = [
("Authorization", f"Basic {self._api_key}"),
("X-User-Agent", USER_AGENT),
("X-Request-Id", request_id),
]
self._websocket = await websocket_connect(self._url, additional_headers=headers)
await self._call_event_handler("on_connected")
except Exception as e:
@@ -780,9 +805,10 @@ class InworldTTSService(AudioContextWordTTSService):
if self._websocket:
logger.debug("Disconnecting from Inworld WebSocket TTS")
if self._context_id:
context_id = self.get_active_audio_context_id()
if context_id:
try:
await self._send_close_context(self._context_id)
await self._send_close_context(context_id)
except Exception:
pass
await self._websocket.close()
@@ -790,7 +816,7 @@ class InworldTTSService(AudioContextWordTTSService):
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
self._context_id = None
await self.remove_active_audio_context()
self._websocket = None
self._cumulative_time = 0.0
self._generation_end_time = 0.0
@@ -816,7 +842,7 @@ class InworldTTSService(AudioContextWordTTSService):
]
logger.debug(
f"{self}: Received message types={msg_types}, ctx_id={ctx_id}, "
f"current_ctx={self._context_id}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}"
f"current_ctx={self.get_active_audio_context_id()}, available={self.audio_context_available(ctx_id) if ctx_id else 'N/A'}"
)
# Check for errors
@@ -828,7 +854,9 @@ class InworldTTSService(AudioContextWordTTSService):
# Handle "Context not found" error (code 5)
# This can happen when a keepalive message is sent but no context is available.
if error_code == 5 and "not found" in error_msg.lower():
logger.debug(f"{self}: Context {ctx_id or self._context_id} not found.")
logger.debug(
f"{self}: Context {ctx_id or self.get_active_audio_context_id()} not found."
)
continue
# For other errors, push error frame
@@ -843,11 +871,9 @@ class InworldTTSService(AudioContextWordTTSService):
# If the context isn't available but matches our current context ID,
# recreate it (handles race conditions during interruption recovery).
if ctx_id and not self.audio_context_available(ctx_id):
if self._context_id == ctx_id:
logger.trace(
f"{self}: Recreating audio context for current context: {self._context_id}"
)
await self.create_audio_context(self._context_id)
if self.get_active_audio_context_id() == ctx_id:
logger.trace(f"{self}: Recreating audio context for current context: {ctx_id}")
await self.create_audio_context(ctx_id)
else:
# This is a message from an old/closed context - skip it
logger.trace(f"{self}: Skipping message from unavailable context: {ctx_id}")
@@ -869,12 +895,12 @@ class InworldTTSService(AudioContextWordTTSService):
if ctx_id:
await self.append_to_audio_context(ctx_id, frame)
# timestampInfo is inside audioChunk
timestamp_info = audio_chunk.get("timestampInfo")
if timestamp_info:
word_times = self._calculate_word_times(timestamp_info)
if word_times:
await self.add_word_timestamps(word_times, ctx_id)
# timestampInfo is inside audioChunk
timestamp_info = audio_chunk.get("timestampInfo")
if timestamp_info:
word_times = self._calculate_word_times(timestamp_info)
if word_times:
await self.add_word_timestamps(word_times, ctx_id)
# Handle context created confirmation
if "contextCreated" in result:
@@ -893,8 +919,8 @@ class InworldTTSService(AudioContextWordTTSService):
logger.trace(f"{self}: Context closed on server: {ctx_id}")
await self.stop_ttfb_metrics()
# Only reset if this is our current context
if ctx_id == self._context_id:
self._context_id = None
if ctx_id == self.get_active_audio_context_id():
self.reset_active_audio_context()
if ctx_id and self.audio_context_available(ctx_id):
await self.remove_audio_context(ctx_id)
await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)], ctx_id)
@@ -906,12 +932,13 @@ class InworldTTSService(AudioContextWordTTSService):
await asyncio.sleep(KEEPALIVE_SLEEP)
try:
if self._websocket and self._websocket.state is State.OPEN:
if self._context_id:
context_id = self.get_active_audio_context_id()
if context_id:
keepalive_message = {
"send_text": {"text": ""},
"contextId": self._context_id,
"contextId": context_id,
}
logger.trace(f"Sending keepalive for context {self._context_id}")
logger.trace(f"Sending keepalive for context {context_id}")
else:
keepalive_message = {"send_text": {"text": ""}}
logger.trace("Sending keepalive without context")
@@ -945,6 +972,10 @@ class InworldTTSService(AudioContextWordTTSService):
create_config["applyTextNormalization"] = self._settings.apply_text_normalization
if is_given(self._settings.auto_mode):
create_config["autoMode"] = self._settings.auto_mode
if is_given(self._settings.timestamp_transport_strategy):
create_config["timestampTransportStrategy"] = (
self._settings.timestamp_transport_strategy
)
# Set buffer settings for timely audio generation.
# Use provided values or defaults that work well for streaming LLM output.
@@ -1003,20 +1034,13 @@ class InworldTTSService(AudioContextWordTTSService):
await self._connect()
try:
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
await self.create_audio_context(context_id)
await self._send_context(context_id)
if not self._context_id:
self._context_id = context_id
logger.trace(f"{self}: Creating new context {self._context_id}")
await self.create_audio_context(self._context_id)
await self._send_context(self._context_id)
elif not self.audio_context_available(self._context_id):
# Context exists on server but local tracking was removed
logger.trace(f"{self}: Recreating local audio context {self._context_id}")
await self.create_audio_context(self._context_id)
await self._send_text(self._context_id, text)
await self._send_text(context_id, text)
await self.start_tts_usage_metrics(text)
except Exception as e:

View File

@@ -202,7 +202,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService):
self._functions: Dict[Optional[str], FunctionCallRegistryItem] = {}
self._function_call_tasks: Dict[Optional[asyncio.Task], FunctionCallRunnerItem] = {}
self._sequential_runner_task: Optional[asyncio.Task] = None
self._tracing_enabled: bool = False
self._skip_tts: Optional[bool] = None
self._summary_task: Optional[asyncio.Task] = None
self._settings = LLMSettings() # Here in case subclass doesn't implement more specific settings (hopefully shouldn't happen)
@@ -290,7 +289,6 @@ class LLMService(UserTurnCompletionLLMServiceMixin, AIService):
await super().start(frame)
if not self._run_in_parallel:
await self._create_sequential_runner_task()
self._tracing_enabled = frame.enable_tracing
async def stop(self, frame: EndFrame):
"""Stop the LLM service.

View File

@@ -393,20 +393,29 @@ class BaseOpenAILLMService(LLMService):
else self._stream_chat_completions_universal_context(context)
)
# Ensure stream is closed on cancellation/exception to prevent socket
# leaks. OpenAI's AsyncStream uses close(), async generators use aclose().
# Ensure stream and its async iterator are closed on cancellation/exception
# to prevent socket leaks and uvloop crashes. Closing the iterator first
# cascades cleanup through nested async generators (httpx/httpcore internals),
# preventing uvloop's broken asyncgen finalizer from firing on Python 3.12+
# (MagicStack/uvloop#699).
@asynccontextmanager
async def _closing(stream):
chunk_iter = stream.__aiter__()
try:
yield stream
yield chunk_iter
finally:
if hasattr(stream, "aclose"):
await stream.aclose()
elif hasattr(stream, "close"):
# Close the iterator first to cascade cleanup through
# nested async generators (httpx/httpcore internals).
if hasattr(chunk_iter, "aclose"):
await chunk_iter.aclose()
# Then close the stream to release HTTP resources.
if hasattr(stream, "close"):
await stream.close()
elif hasattr(stream, "aclose"):
await stream.aclose()
async with _closing(chunk_stream):
async for chunk in chunk_stream:
async with _closing(chunk_stream) as chunk_iter:
async for chunk in chunk_iter:
if chunk.usage:
cached_tokens = (
chunk.usage.prompt_tokens_details.cached_tokens

View File

@@ -13,6 +13,7 @@ supporting both WebSocket streaming and HTTP-based synthesis.
import io
import json
import struct
import uuid
import warnings
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, Optional
@@ -366,6 +367,20 @@ class PlayHTTTSService(InterruptibleTTSService):
return self._websocket
raise Exception("Websocket not connected")
def create_context_id(self) -> str:
"""Generate a unique context ID for a TTS request in case we don't have one already in progress.
Returns:
A unique string identifier for the TTS context.
"""
# If a context ID does not exist, create a new one.
# If an ID exists, continue using the current ID.
# When interruptions happen, user speech results in
# an interruption, which resets the context ID.
if not self._context_id:
return str(uuid.uuid4())
return self._context_id
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by stopping metrics and clearing request ID."""
await super()._handle_interruption(frame, direction)

View File

@@ -27,8 +27,6 @@ from pipecat.frames.frames import (
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.settings import NOT_GIVEN, TTSSettings, _NotGiven
from pipecat.services.tts_service import AudioContextWordTTSService
from pipecat.transcriptions.language import Language
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
from pipecat.utils.tracing.service_decorators import traced_tts
try:
@@ -94,6 +92,7 @@ class ResembleAITTSService(AudioContextWordTTSService):
"""
super().__init__(
sample_rate=sample_rate,
reuse_context_id_within_turn=False,
**kwargs,
)

View File

@@ -77,22 +77,32 @@ class RimeTTSSettings(TTSSettings):
Parameters:
audioFormat: Audio output format.
samplingRate: Audio sample rate.
lang: Rime language code.
speedAlpha: Speech speed multiplier. Defaults to 1.0.
reduceLatency: Whether to reduce latency at potential quality cost.
pauseBetweenBrackets: Whether to add pauses between bracketed content.
phonemizeBetweenBrackets: Whether to phonemize bracketed content.
segment: Text segmentation mode ("immediate", "bySentence", "never").
speedAlpha: Speech speed multiplier (mistv2 only).
reduceLatency: Whether to reduce latency at potential quality cost (mistv2 only).
pauseBetweenBrackets: Whether to add pauses between bracketed content (mistv2 only).
phonemizeBetweenBrackets: Whether to phonemize bracketed content (mistv2 only).
noTextNormalization: Whether to disable text normalization (mistv2 only).
saveOovs: Whether to save out-of-vocabulary words (mistv2 only).
inlineSpeedAlpha: Inline speed control markup.
repetition_penalty: Token repetition penalty (arcana only, 1.0-2.0).
temperature: Sampling temperature (arcana only, 0.0-1.0).
top_p: Cumulative probability threshold (arcana only, 0.0-1.0).
"""
audioFormat: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
samplingRate: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
lang: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
segment: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
speedAlpha: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
reduceLatency: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
pauseBetweenBrackets: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
phonemizeBetweenBrackets: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
noTextNormalization: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
saveOovs: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
inlineSpeedAlpha: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
repetition_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
top_p: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
_aliases: ClassVar[Dict[str, str]] = {"speaker": "voice"}
@@ -104,7 +114,6 @@ class RimeNonJsonTTSSettings(TTSSettings):
Parameters:
audioFormat: Audio output format.
samplingRate: Audio sample rate.
lang: Rime language code.
segment: Text segmentation mode ("immediate", "bySentence", "never").
repetition_penalty: Token repetition penalty (1.0-2.0).
temperature: Sampling temperature (0.0-1.0).
@@ -113,7 +122,6 @@ class RimeNonJsonTTSSettings(TTSSettings):
audioFormat: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
samplingRate: int | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
lang: str | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
segment: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
repetition_penalty: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
@@ -137,25 +145,39 @@ class RimeTTSService(AudioContextWordTTSService):
Parameters:
language: Language for synthesis. Defaults to English.
speed_alpha: Speech speed multiplier. Defaults to 1.0.
reduce_latency: Whether to reduce latency at potential quality cost.
pause_between_brackets: Whether to add pauses between bracketed content.
phonemize_between_brackets: Whether to phonemize bracketed content.
segment: Text segmentation mode ("immediate", "bySentence", "never").
repetition_penalty: Token repetition penalty (arcana only).
temperature: Sampling temperature (arcana only).
top_p: Cumulative probability threshold (arcana only).
speed_alpha: Speech speed multiplier (mistv2 only).
reduce_latency: Whether to reduce latency at potential quality cost (mistv2 only).
pause_between_brackets: Whether to add pauses between bracketed content (mistv2 only).
phonemize_between_brackets: Whether to phonemize bracketed content (mistv2 only).
no_text_normalization: Whether to disable text normalization (mistv2 only).
save_oovs: Whether to save out-of-vocabulary words (mistv2 only).
"""
language: Optional[Language] = Language.EN
speed_alpha: Optional[float] = 1.0
reduce_latency: Optional[bool] = False
pause_between_brackets: Optional[bool] = False
phonemize_between_brackets: Optional[bool] = False
segment: Optional[str] = None
# Arcana params
repetition_penalty: Optional[float] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
# Mistv2 params
speed_alpha: Optional[float] = None
reduce_latency: Optional[bool] = None
pause_between_brackets: Optional[bool] = None
phonemize_between_brackets: Optional[bool] = None
no_text_normalization: Optional[bool] = None
save_oovs: Optional[bool] = None
def __init__(
self,
*,
api_key: str,
voice_id: str,
url: str = "wss://users.rime.ai/ws2",
model: str = "mistv2",
url: str = "wss://users-ws.rime.ai/ws3",
model: str = "arcana",
sample_rate: Optional[int] = None,
params: Optional[InputParams] = None,
text_aggregator: Optional[BaseTextAggregator] = None,
@@ -203,22 +225,38 @@ class RimeTTSService(AudioContextWordTTSService):
# Store service configuration
self._api_key = api_key
self._url = url
self._model = model
self._settings = RimeTTSSettings(
voice=voice_id,
model=model,
voice=voice_id,
audioFormat="pcm",
samplingRate=0,
lang=self.language_to_service_language(params.language) if params.language else "eng",
speedAlpha=params.speed_alpha,
reduceLatency=params.reduce_latency,
pauseBetweenBrackets=json.dumps(params.pause_between_brackets),
phonemizeBetweenBrackets=json.dumps(params.phonemize_between_brackets),
samplingRate=0, # updated in start()
language=self.language_to_service_language(params.language)
if params.language
else NOT_GIVEN,
segment=params.segment if params.segment is not None else NOT_GIVEN,
# Arcana params
repetition_penalty=params.repetition_penalty
if params.repetition_penalty is not None
else NOT_GIVEN,
temperature=params.temperature if params.temperature is not None else NOT_GIVEN,
top_p=params.top_p if params.top_p is not None else NOT_GIVEN,
# Mistv2 params
speedAlpha=params.speed_alpha if params.speed_alpha is not None else NOT_GIVEN,
reduceLatency=params.reduce_latency if params.reduce_latency is not None else NOT_GIVEN,
pauseBetweenBrackets=params.pause_between_brackets
if params.pause_between_brackets is not None
else NOT_GIVEN,
phonemizeBetweenBrackets=params.phonemize_between_brackets
if params.phonemize_between_brackets is not None
else NOT_GIVEN,
noTextNormalization=params.no_text_normalization
if params.no_text_normalization is not None
else NOT_GIVEN,
saveOovs=params.save_oovs if params.save_oovs is not None else NOT_GIVEN,
)
self._sync_model_name_to_metrics()
# State tracking
self._context_id = None # Tracks current turn
self._receive_task = None
self._cumulative_time = 0 # Accumulates time across messages
self._extra_msg_fields = {} # Extra fields for next message
@@ -242,6 +280,50 @@ class RimeTTSService(AudioContextWordTTSService):
"""
return language_to_rime_language(language)
def _build_ws_params(self) -> dict[str, Any]:
"""Build query params for the WebSocket URL from current settings.
Returns:
Dictionary of query parameters for the WebSocket URL.
Only explicitly-set values are included. Boolean mistv2 params
are serialized with ``json.dumps()`` for the wire format.
"""
params: dict[str, Any] = {
"speaker": self._settings.voice,
"modelId": self._settings.model,
"audioFormat": self._settings.audioFormat,
"samplingRate": self._settings.samplingRate,
}
if is_given(self._settings.language):
params["lang"] = self._settings.language
if is_given(self._settings.segment):
params["segment"] = self._settings.segment
if self._settings.model == "arcana":
if is_given(self._settings.repetition_penalty):
params["repetition_penalty"] = self._settings.repetition_penalty
if is_given(self._settings.temperature):
params["temperature"] = self._settings.temperature
if is_given(self._settings.top_p):
params["top_p"] = self._settings.top_p
else: # mistv2/mist
if is_given(self._settings.speedAlpha):
params["speedAlpha"] = self._settings.speedAlpha
if is_given(self._settings.reduceLatency):
params["reduceLatency"] = self._settings.reduceLatency
if is_given(self._settings.pauseBetweenBrackets):
params["pauseBetweenBrackets"] = json.dumps(self._settings.pauseBetweenBrackets)
if is_given(self._settings.phonemizeBetweenBrackets):
params["phonemizeBetweenBrackets"] = json.dumps(
self._settings.phonemizeBetweenBrackets
)
if is_given(self._settings.noTextNormalization):
params["noTextNormalization"] = json.dumps(self._settings.noTextNormalization)
if is_given(self._settings.saveOovs):
params["saveOovs"] = json.dumps(self._settings.saveOovs)
return params
# A set of Rime-specific helpers for text transformations
def SPELL(text: str) -> str:
"""Wrap text in Rime spell function."""
@@ -268,18 +350,22 @@ class RimeTTSService(AudioContextWordTTSService):
return f"[{text}]"
async def _update_settings(self, update: TTSSettings) -> dict[str, Any]:
"""Apply a settings update and reconnect if voice changed."""
"""Apply a settings update and reconnect if necessary.
Since all settings are WebSocket URL query parameters,
any setting change requires reconnecting to apply the new values.
"""
changed = await super()._update_settings(update)
if "voice" in changed:
if changed and self._websocket:
await self._disconnect()
await self._connect()
else:
self._warn_unhandled_updated_settings(changed)
return changed
def _build_msg(self, text: str = "") -> dict:
"""Build JSON message for Rime API."""
msg = {"text": text, "contextId": self._context_id}
msg = {"text": text, "contextId": self.get_active_audio_context_id()}
if self._extra_msg_fields:
msg |= self._extra_msg_fields
self._extra_msg_fields = {}
@@ -346,20 +432,8 @@ class RimeTTSService(AudioContextWordTTSService):
if self._websocket and self._websocket.state is State.OPEN:
return
params = "&".join(
f"{k}={v}"
for k, v in {
"speaker": self._settings.voice,
"modelId": self._settings.model,
"audioFormat": self._settings.audioFormat,
"samplingRate": self._settings.samplingRate,
"lang": self._settings.lang,
"speedAlpha": self._settings.speedAlpha,
"reduceLatency": self._settings.reduceLatency,
"pauseBetweenBrackets": self._settings.pauseBetweenBrackets,
"phonemizeBetweenBrackets": self._settings.phonemizeBetweenBrackets,
}.items()
)
ws_params = self._build_ws_params()
params = "&".join(f"{k}={v}" for k, v in ws_params.items() if v is not None)
url = f"{self._url}?{params}"
headers = {"Authorization": f"Bearer {self._api_key}"}
self._websocket = await websocket_connect(url, additional_headers=headers)
@@ -380,7 +454,7 @@ class RimeTTSService(AudioContextWordTTSService):
except Exception as e:
await self.push_error(error_msg=f"Error disconnecting: {e}", exception=e)
finally:
self._context_id = None
await self.remove_active_audio_context()
self._websocket = None
await self._call_event_handler("on_disconnected")
@@ -392,11 +466,11 @@ class RimeTTSService(AudioContextWordTTSService):
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
"""Handle interruption by clearing current context."""
context_id = self.get_active_audio_context_id()
await super()._handle_interruption(frame, direction)
await self.stop_all_metrics()
if self._context_id:
if context_id:
await self._get_websocket().send(json.dumps(self._build_clear_msg()))
self._context_id = None
def _calculate_word_times(self, words: list, starts: list, ends: list) -> list:
"""Calculate word timing pairs with proper spacing and punctuation.
@@ -429,19 +503,20 @@ class RimeTTSService(AudioContextWordTTSService):
async def flush_audio(self):
"""Flush any pending audio synthesis."""
if not self._context_id or not self._websocket:
context_id = self.get_active_audio_context_id()
if not context_id or not self._websocket:
return
logger.trace(f"{self}: flushing audio")
await self._get_websocket().send(json.dumps({"operation": "flush"}))
self._context_id = None
self.reset_active_audio_context()
async def _receive_messages(self):
"""Process incoming websocket messages."""
async for message in self._get_websocket():
msg = json.loads(message)
if not msg or not self.audio_context_available(msg["contextId"]):
if not msg or not self.audio_context_available(msg.get("contextId")):
continue
context_id = msg["contextId"]
@@ -476,7 +551,7 @@ class RimeTTSService(AudioContextWordTTSService):
await self.push_frame(TTSStoppedFrame())
await self.stop_all_metrics()
await self.push_error(error_msg=f"Error: {msg['message']}")
self._context_id = None
self.reset_active_audio_context()
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
"""Push frame and handle end-of-turn conditions.
@@ -507,12 +582,11 @@ class RimeTTSService(AudioContextWordTTSService):
await self._connect()
try:
if not self._context_id:
if not self.has_active_audio_context():
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
self._cumulative_time = 0
self._context_id = context_id
await self.create_audio_context(self._context_id)
await self.create_audio_context(context_id)
msg = self._build_msg(text=text)
await self._get_websocket().send(json.dumps(msg))
@@ -587,7 +661,9 @@ class RimeHttpTTSService(TTSService):
self._base_url = "https://users.rime.ai/v1/rime-tts"
self._settings = RimeTTSSettings(
model=model,
lang=self.language_to_service_language(params.language) if params.language else "eng",
language=self.language_to_service_language(params.language)
if params.language
else "eng",
speedAlpha=params.speed_alpha,
reduceLatency=params.reduce_latency,
pauseBetweenBrackets=params.pause_between_brackets,
@@ -636,7 +712,7 @@ class RimeHttpTTSService(TTSService):
}
payload = {
"lang": self._settings.lang,
"lang": self._settings.language,
"speedAlpha": self._settings.speedAlpha,
"reduceLatency": self._settings.reduceLatency,
"pauseBetweenBrackets": self._settings.pauseBetweenBrackets,
@@ -691,20 +767,18 @@ class RimeHttpTTSService(TTSService):
class RimeNonJsonTTSService(InterruptibleTTSService):
"""Pipecat TTS service for Rime's non-JSON WebSocket API.
.. deprecated:: 0.0.102
Arcana now supports JSON WebSocket with word-level timestamps via the
``wss://users-ws.rime.ai/ws3`` endpoint. Use :class:`RimeTTSService`
with ``model="arcana"`` instead.
This service enables Text-to-Speech synthesis over WebSocket endpoints
that require plain text (not JSON) messages and return raw audio bytes.
It is designed for use with TTS models like Arcana, which currently do
not support JSON-based WebSocket protocols (though this may change in
the future).
Limitations:
- Does not support word-level timestamps or context IDs.
- Intended specifically for integrations where the TTS provider only
accepts and returns non-JSON messages.
Note:
- Arcana and similar models may add JSON WebSocket support in the
future. This service focuses on the current plain text protocol.
"""
_settings: RimeNonJsonTTSSettings
@@ -764,13 +838,12 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
params = params or RimeNonJsonTTSService.InputParams()
self._api_key = api_key
self._url = url
self._model = model
self._settings = RimeNonJsonTTSSettings(
voice=voice_id,
model=model,
audioFormat=audio_format,
samplingRate=sample_rate,
lang=self.language_to_service_language(params.language)
language=self.language_to_service_language(params.language)
if params.language
else NOT_GIVEN,
segment=params.segment if params.segment is not None else NOT_GIVEN,
@@ -866,8 +939,8 @@ class RimeNonJsonTTSService(InterruptibleTTSService):
"audioFormat": self._settings.audioFormat,
"samplingRate": self._settings.samplingRate,
}
if is_given(self._settings.lang):
settings_dict["lang"] = self._settings.lang
if is_given(self._settings.language):
settings_dict["lang"] = self._settings.language
if is_given(self._settings.segment):
settings_dict["segment"] = self._settings.segment
if is_given(self._settings.repetition_penalty):

View File

@@ -120,10 +120,10 @@ MODEL_CONFIGS: Dict[str, ModelConfig] = {
use_translate_method=True,
),
"saaras:v3": ModelConfig(
supports_prompt=True,
supports_prompt=False,
supports_mode=True,
supports_language=True,
default_language="en-IN",
default_language="unknown",
default_mode="transcribe",
use_translate_endpoint=False,
use_translate_method=False,
@@ -152,6 +152,18 @@ class SarvamSTTService(STTService):
"""Sarvam speech-to-text service.
Provides real-time speech recognition using Sarvam's WebSocket API.
Event handlers available (in addition to STTService events):
- on_connected(service): Connected to Sarvam WebSocket
- on_disconnected(service): Disconnected from Sarvam WebSocket
- on_connection_error(service, error): Connection error occurred
Example::
@stt.event_handler("on_connected")
async def on_connected(service):
...
"""
_settings: SarvamSTTSettings
@@ -163,9 +175,9 @@ class SarvamSTTService(STTService):
language: Target language for transcription.
- saarika:v2.5: Defaults to "unknown" (auto-detect supported)
- saaras:v2.5: Not used (auto-detects language)
- saaras:v3: Defaults to "en-IN"
- saaras:v3: Defaults to "unknown" (auto-detect supported)
prompt: Optional prompt to guide transcription/translation style/context.
Only applicable to saaras models (v2.5 and v3). Defaults to None.
Only applicable to saaras:v2.5. Defaults to None.
mode: Mode of operation for saaras:v3 models only. Options: transcribe, translate,
verbatim, translit, codemix. Defaults to "transcribe" for saaras:v3.
vad_signals: Enable VAD signals in response. Defaults to None.
@@ -187,6 +199,8 @@ class SarvamSTTService(STTService):
input_audio_codec: str = "wav",
params: Optional[InputParams] = None,
ttfs_p99_latency: Optional[float] = SARVAM_TTFS_P99,
keepalive_timeout: Optional[float] = None,
keepalive_interval: float = 5.0,
**kwargs,
):
"""Initialize the Sarvam STT service.
@@ -196,12 +210,15 @@ class SarvamSTTService(STTService):
model: Sarvam model to use for transcription. Allowed values:
- "saarika:v2.5": Standard STT model
- "saaras:v2.5": STT-Translate model (auto-detects language, supports prompts)
- "saaras:v3": Advanced STT model (supports mode and prompts)
- "saaras:v3": Advanced STT model (supports mode)
sample_rate: Audio sample rate. Defaults to 16000 if not specified.
input_audio_codec: Audio codec/format of the input file. Defaults to "wav".
params: Configuration parameters for Sarvam STT service.
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
keepalive_timeout: Seconds of no audio before sending silence to keep the
connection alive. None disables keepalive.
keepalive_interval: Seconds between idle checks when keepalive is enabled.
**kwargs: Additional arguments passed to the parent STTService.
"""
params = params or SarvamSTTService.InputParams()
@@ -223,7 +240,13 @@ class SarvamSTTService(STTService):
f"Model '{model}' does not support language parameter (auto-detects language)."
)
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
super().__init__(
sample_rate=sample_rate,
ttfs_p99_latency=ttfs_p99_latency,
keepalive_timeout=keepalive_timeout,
keepalive_interval=keepalive_interval,
**kwargs,
)
self._api_key = api_key
@@ -490,13 +513,36 @@ class SarvamSTTService(STTService):
if self._config.supports_mode and is_given(self._settings.mode):
connect_kwargs["mode"] = self._settings.mode
# Prompt support differs across sarvamai versions. Prefer connect-time prompt
# when available and gracefully degrade if the SDK doesn't accept it.
if (
is_given(self._settings.prompt)
and self._settings.prompt is not None
and self._config.supports_prompt
):
connect_kwargs["prompt"] = self._settings.prompt
def _connect_with_sdk_headers(connect_fn, **kwargs):
# Different SDK versions may use different kwarg names.
for header_kw in ("headers", "additional_headers", "extra_headers"):
# If prompt is unsupported at connect-time, retry without it.
attempts = [kwargs]
if "prompt" in kwargs:
attempts.append({k: v for k, v in kwargs.items() if k != "prompt"})
last_type_error = None
for attempt_kwargs in attempts:
for header_kw in ("headers", "additional_headers", "extra_headers"):
try:
return connect_fn(**attempt_kwargs, **{header_kw: self._sdk_headers})
except TypeError as e:
last_type_error = e
try:
return connect_fn(**kwargs, **{header_kw: self._sdk_headers})
except TypeError:
pass
return connect_fn(**attempt_kwargs)
except TypeError as e:
last_type_error = e
if last_type_error is not None:
raise last_type_error
return connect_fn(**kwargs)
# Choose the appropriate endpoint based on model configuration
@@ -514,9 +560,15 @@ class SarvamSTTService(STTService):
# Enter the async context manager
self._socket_client = await self._websocket_context.__aenter__()
# Set prompt if provided (only for models that support prompts)
if is_given(self._settings.prompt) and self._config.supports_prompt:
await self._socket_client.set_prompt(self._settings.prompt)
# Fallback for SDKs that support runtime prompt updates.
if (
is_given(self._settings.prompt)
and self._settings.prompt is not None
and self._config.supports_prompt
):
prompt_setter = getattr(self._socket_client, "set_prompt", None)
if callable(prompt_setter):
await prompt_setter(self._settings.prompt)
# Register event handler for incoming messages
def _message_handler(message):
@@ -529,6 +581,8 @@ class SarvamSTTService(STTService):
# Start receive task using Pipecat's task management
self._receive_task = self.create_task(self._receive_task_handler())
self._create_keepalive_task()
logger.info("Connected to Sarvam successfully")
except ApiError as e:
@@ -542,6 +596,8 @@ class SarvamSTTService(STTService):
async def _disconnect(self):
"""Disconnect from Sarvam WebSocket API using SDK."""
await self._cancel_keepalive_task()
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
@@ -668,6 +724,32 @@ class SarvamSTTService(STTService):
}
return mapping.get(language_code, Language.HI_IN)
def _is_keepalive_ready(self) -> bool:
"""Check if the Sarvam SDK websocket client is connected."""
return self._socket_client is not None
async def _send_keepalive(self, silence: bytes):
"""Send silent audio via the Sarvam SDK to keep the connection alive.
Args:
silence: Silent 16-bit mono PCM audio bytes.
"""
audio_base64 = base64.b64encode(silence).decode("utf-8")
encoding = (
self._input_audio_codec
if self._input_audio_codec.startswith("audio/")
else f"audio/{self._input_audio_codec}"
)
method_kwargs = {
"audio": audio_base64,
"encoding": encoding,
"sample_rate": self.sample_rate,
}
if self._config.use_translate_method:
await self._socket_client.translate(**method_kwargs)
else:
await self._socket_client.transcribe(**method_kwargs)
async def _start_metrics(self):
"""Start processing metrics collection."""
await self.start_processing_metrics()

View File

@@ -131,7 +131,6 @@ class SimliVideoService(FrameProcessor):
# Build SimliConfig from new parameters
# Only pass optional parameters if explicitly provided to use SimliConfig defaults
config_kwargs = {
"apiKey": api_key,
"faceId": face_id,
}
if params.max_session_length is not None:
@@ -153,10 +152,10 @@ class SimliVideoService(FrameProcessor):
config.maxIdleTime += 5
config.maxSessionLength += 5
self._simli_client = SimliClient(
api_key=api_key,
config=config,
latencyInterval=latency_interval,
simliURL=simli_url,
enable_logging=params.enable_logging or False,
enableSFU=True,
)
self._pipecat_resampler: AudioResampler = None
@@ -173,7 +172,7 @@ class SimliVideoService(FrameProcessor):
"""Start the connection to Simli service and begin processing tasks."""
try:
if not self._initialized:
await self._simli_client.Initialize()
await self._simli_client.start()
self._initialized = True
# Create task to consume and process audio and video

View File

@@ -166,6 +166,16 @@ class SpeechmaticsSTTService(STTService):
This service provides real-time speech-to-text transcription using the Speechmatics API.
It supports partial and final transcriptions, multiple languages, various audio formats,
and speaker diarization.
Event handlers available (in addition to STTService events):
- on_speakers_result(service, speakers): Speaker diarization results received
Example::
@stt.event_handler("on_speakers_result")
async def on_speakers_result(service, speakers):
...
"""
_settings: SpeechmaticsSTTSettings

View File

@@ -22,7 +22,6 @@ from pipecat.frames.frames import (
ErrorFrame,
Frame,
InterruptionFrame,
MetricsFrame,
ServiceSwitcherRequestMetadataFrame,
StartFrame,
STTMetadataFrame,
@@ -32,7 +31,6 @@ from pipecat.frames.frames import (
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import TTFBMetricsData
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_service import AIService
from pipecat.services.settings import STTSettings, is_given
@@ -51,6 +49,12 @@ class STTService(AIService):
muting, settings management, and audio processing. Subclasses must implement
the run_stt method to provide actual speech recognition.
Includes an optional keepalive mechanism that sends silent audio when no real
audio has been sent for a configurable timeout, preventing servers from closing
idle connections (e.g. when behind a ServiceSwitcher). Subclasses that enable
keepalive must override ``_send_keepalive()`` to deliver the silence in the
appropriate service-specific protocol.
Event handlers:
on_connected: Called when connected to the STT service.
on_disconnected: Called when disconnected from the STT service.
@@ -80,6 +84,8 @@ class STTService(AIService):
sample_rate: Optional[int] = None,
stt_ttfb_timeout: float = 2.0,
ttfs_p99_latency: Optional[float] = None,
keepalive_timeout: Optional[float] = None,
keepalive_interval: float = 5.0,
**kwargs,
):
"""Initialize the STT service.
@@ -99,14 +105,18 @@ class STTService(AIService):
This is broadcast via STTMetadataFrame at pipeline start for downstream
processors (e.g., turn strategies) to optimize timing. Subclasses provide
measured defaults; pass a value here to override for your deployment.
keepalive_timeout: Seconds of no audio before sending silence to keep the
connection alive. None disables keepalive. Useful for services that
close idle connections (e.g. behind a ServiceSwitcher).
keepalive_interval: Seconds between idle checks when keepalive is enabled.
**kwargs: Additional arguments passed to the parent AIService.
"""
super().__init__(**kwargs)
self._audio_passthrough = audio_passthrough
self._init_sample_rate = sample_rate
self._sample_rate = 0
self._settings = STTSettings() # Here in case subclass doesn't implement more specific settings (hopefully shouldn't happen)
self._tracing_enabled: bool = False
self._muted: bool = False
self._user_id: str = ""
self._ttfs_p99_latency = ttfs_p99_latency
@@ -114,12 +124,16 @@ class STTService(AIService):
# STT TTFB tracking state
self._stt_ttfb_timeout = stt_ttfb_timeout
self._ttfb_timeout_task: Optional[asyncio.Task] = None
self._speech_end_time: Optional[float] = None
self._user_speaking: bool = False
self._last_transcription_time: Optional[float] = None
self._finalize_pending: bool = False
self._finalize_requested: bool = False
# Keepalive state
self._keepalive_timeout = keepalive_timeout
self._keepalive_interval = keepalive_interval
self._keepalive_task: Optional[asyncio.Task] = None
self._last_audio_time: float = 0
self._register_event_handler("on_connected")
self._register_event_handler("on_disconnected")
self._register_event_handler("on_connection_error")
@@ -241,12 +255,12 @@ class STTService(AIService):
"""
await super().start(frame)
self._sample_rate = self._init_sample_rate or frame.audio_in_sample_rate
self._tracing_enabled = frame.enable_tracing
async def cleanup(self):
"""Clean up STT service resources."""
await super().cleanup()
await self._cancel_ttfb_timeout()
await self._cancel_keepalive_task()
async def _update_settings(self, update: STTSettings) -> dict[str, Any]:
"""Apply an STT settings update.
@@ -287,6 +301,8 @@ class STTService(AIService):
if self._muted:
return
self._last_audio_time = time.monotonic()
# UserAudioRawFrame contains a user_id (e.g. Daily, Livekit)
if hasattr(frame, "user_id"):
self._user_id = frame.user_id
@@ -369,23 +385,16 @@ class STTService(AIService):
direction: The direction to push the frame.
"""
if isinstance(frame, TranscriptionFrame):
# Store the transcription time for TTFB calculation
self._last_transcription_time = time.time()
# Set finalized from pending state and auto-reset
if self._finalize_pending:
frame.finalized = True
self._finalize_pending = False
# If this is a finalized transcription, report TTFB immediately
if frame.finalized and self._speech_end_time is not None:
ttfb = self._last_transcription_time - self._speech_end_time
await self._emit_stt_ttfb_metric(ttfb)
if frame.finalized:
await self.stop_ttfb_metrics()
# Cancel the timeout since we've already reported
await self._cancel_ttfb_timeout()
# Clear state
self._speech_end_time = None
self._last_transcription_time = None
await super().push_frame(frame, direction)
@@ -415,8 +424,6 @@ class STTService(AIService):
while user is still speaking.
"""
await self._cancel_ttfb_timeout()
self._speech_end_time = None
self._last_transcription_time = None
async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame):
"""Handle VAD user started speaking frame to start tracking transcriptions.
@@ -450,7 +457,8 @@ class STTService(AIService):
# Calculate the actual speech end time (current time minus VAD stop delay).
# This approximates when the last user audio was sent to the STT service,
# which we use to measure against the eventual transcription response.
self._speech_end_time = frame.timestamp - frame.stop_secs
speech_end_time = frame.timestamp - frame.stop_secs
await self.start_ttfb_metrics(start_time=speech_end_time)
# Start timeout task (any previous timeout was cancelled by VADUserStartedSpeakingFrame
# or InterruptionFrame)
@@ -459,43 +467,79 @@ class STTService(AIService):
)
async def _ttfb_timeout_handler(self):
"""Wait for timeout then report TTFB using the last transcription timestamp.
"""Wait for timeout then report TTFB.
This timeout allows the final transcription to arrive before we calculate
and report TTFB. If no transcription arrived, no TTFB is reported.
"""
try:
await asyncio.sleep(self._stt_ttfb_timeout)
# Report TTFB if we have both speech end time and transcription time
if self._speech_end_time is not None and self._last_transcription_time is not None:
ttfb = self._last_transcription_time - self._speech_end_time
await self._emit_stt_ttfb_metric(ttfb)
# Clear state after reporting
self._speech_end_time = None
self._last_transcription_time = None
await self.stop_ttfb_metrics()
except asyncio.CancelledError:
# Task was cancelled (new utterance or interruption), which is expected behavior
pass
finally:
self._ttfb_timeout_task = None
async def _emit_stt_ttfb_metric(self, ttfb: float):
"""Emit STT TTFB metric if value is non-negative.
def _create_keepalive_task(self):
"""Start the keepalive task if keepalive is enabled."""
if self._keepalive_timeout is not None:
self._last_audio_time = time.monotonic()
self._keepalive_task = self.create_task(
self._keepalive_task_handler(), name="keepalive"
)
async def _cancel_keepalive_task(self):
"""Stop the keepalive task if running."""
if self._keepalive_task:
await self.cancel_task(self._keepalive_task)
self._keepalive_task = None
async def _keepalive_task_handler(self):
"""Send periodic silent audio to prevent the server from closing the connection.
When keepalive is enabled, this task checks periodically if the connection
has been idle (no audio sent) for longer than keepalive_timeout seconds.
If so, it generates silent 16-bit mono PCM audio and passes it to
_send_keepalive() for service-specific formatting and sending.
"""
while True:
await asyncio.sleep(self._keepalive_interval)
try:
if not self._is_keepalive_ready():
continue
elapsed = time.monotonic() - self._last_audio_time
if elapsed < self._keepalive_timeout:
continue
num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION)
silence = b"\x00" * (num_samples * 2)
await self._send_keepalive(silence)
self._last_audio_time = time.monotonic()
logger.trace(f"{self} sent keepalive silence")
except Exception as e:
logger.warning(f"{self} keepalive error: {e}")
break
def _is_keepalive_ready(self) -> bool:
"""Check if the service is ready to send keepalive.
Subclasses should override this to check their connection state.
Returns:
True if keepalive can be sent.
"""
return True
async def _send_keepalive(self, silence: bytes):
"""Send silent audio to keep the connection alive.
Subclasses that enable keepalive must override this to deliver silence
in their service-specific protocol.
Args:
ttfb: The TTFB value in seconds.
silence: Silent 16-bit mono PCM audio bytes.
"""
if ttfb >= 0:
logger.debug(f"{self} TTFB: {ttfb:.3f}s")
if self.metrics_enabled:
ttfb_data = TTFBMetricsData(
processor=self.name,
model=self._settings.model,
value=ttfb,
)
await super().push_frame(MetricsFrame(data=[ttfb_data]))
raise NotImplementedError("Subclasses must override _send_keepalive")
class SegmentedSTTService(STTService):
@@ -610,46 +654,27 @@ class WebsocketSTTService(STTService, WebsocketService):
Combines STT functionality with websocket connectivity, providing automatic
error handling, reconnection capabilities, and optional silence-based keepalive.
The keepalive feature sends silent audio when no real audio has been sent for
a configurable timeout, preventing servers from closing idle connections (e.g.
when behind a ServiceSwitcher). Subclasses can override ``_send_keepalive()``
to wrap the silence in a service-specific protocol.
The keepalive feature (inherited from STTService) sends silent audio when no
real audio has been sent for a configurable timeout, preventing servers from
closing idle connections (e.g. when behind a ServiceSwitcher). Subclasses can
override ``_send_keepalive()`` to wrap the silence in a service-specific protocol.
"""
def __init__(
self,
*,
reconnect_on_error: bool = True,
keepalive_timeout: Optional[float] = None,
keepalive_interval: float = 5.0,
**kwargs,
):
"""Initialize the Websocket STT service.
Args:
reconnect_on_error: Whether to automatically reconnect on websocket errors.
keepalive_timeout: Seconds of no audio before sending silence to keep the
connection alive. None disables keepalive. Useful for services that
close idle connections (e.g. behind a ServiceSwitcher).
keepalive_interval: Seconds between idle checks when keepalive is enabled.
**kwargs: Additional arguments passed to parent classes.
**kwargs: Additional arguments passed to parent classes (including
keepalive_timeout and keepalive_interval for STTService).
"""
STTService.__init__(self, **kwargs)
WebsocketService.__init__(self, reconnect_on_error=reconnect_on_error, **kwargs)
self._keepalive_timeout = keepalive_timeout
self._keepalive_interval = keepalive_interval
self._keepalive_task: Optional[asyncio.Task] = None
self._last_audio_time: float = 0
async def process_audio_frame(self, frame: AudioRawFrame, direction: FrameDirection):
"""Process an audio frame, tracking the last audio time for keepalive.
Args:
frame: The audio frame to process.
direction: The direction of frame processing.
"""
self._last_audio_time = time.monotonic()
await super().process_audio_frame(frame, direction)
async def _connect(self):
"""Connect and start keepalive task if enabled."""
@@ -673,44 +698,9 @@ class WebsocketSTTService(STTService, WebsocketService):
self._create_keepalive_task()
return result
def _create_keepalive_task(self):
"""Start the keepalive task if keepalive is enabled."""
if self._keepalive_timeout is not None:
self._last_audio_time = time.monotonic()
self._keepalive_task = self.create_task(
self._keepalive_task_handler(), name="keepalive"
)
async def _cancel_keepalive_task(self):
"""Stop the keepalive task if running."""
if self._keepalive_task:
await self.cancel_task(self._keepalive_task)
self._keepalive_task = None
async def _keepalive_task_handler(self):
"""Send periodic silent audio to prevent the server from closing the connection.
When keepalive is enabled, this task checks periodically if the connection
has been idle (no audio sent) for longer than keepalive_timeout seconds.
If so, it generates silent 16-bit mono PCM audio and passes it to
_send_keepalive() for service-specific formatting and sending.
"""
while True:
await asyncio.sleep(self._keepalive_interval)
try:
if not self._websocket or self._websocket.state is not State.OPEN:
continue
elapsed = time.monotonic() - self._last_audio_time
if elapsed < self._keepalive_timeout:
continue
num_samples = int(self.sample_rate * _KEEPALIVE_SILENCE_DURATION)
silence = b"\x00" * (num_samples * 2)
await self._send_keepalive(silence)
self._last_audio_time = time.monotonic()
logger.trace(f"{self} sent keepalive silence")
except Exception as e:
logger.warning(f"{self} keepalive error: {e}")
break
def _is_keepalive_ready(self) -> bool:
"""Check if the websocket is open and ready for keepalive."""
return self._websocket is not None and self._websocket.state is State.OPEN
async def _send_keepalive(self, silence: bytes):
"""Send silent audio over the websocket to keep the connection alive.

View File

@@ -212,8 +212,6 @@ class TTSService(AIService):
# TODO: Deprecate _text_filters when added to LLMTextProcessor
self._text_filters: Sequence[BaseTextFilter] = text_filters or []
self._transport_destination: Optional[str] = transport_destination
self._tracing_enabled: bool = False
if text_filter:
import warnings
@@ -368,7 +366,6 @@ class TTSService(AIService):
self._sample_rate = self._init_sample_rate or frame.audio_out_sample_rate
if self._push_stop_frames and not self._stop_frame_task:
self._stop_frame_task = self.create_task(self._stop_frame_handler())
self._tracing_enabled = frame.enable_tracing
async def stop(self, frame: EndFrame):
"""Stop the TTS service.
@@ -1081,14 +1078,25 @@ class AudioContextTTSService(WebsocketTTSService):
audio from context ID "A" will be played first.
"""
def __init__(self, *, reconnect_on_error: bool = True, **kwargs):
_CONTEXT_KEEPALIVE = object()
def __init__(
self,
*,
reuse_context_id_within_turn: bool = True,
reconnect_on_error: bool = True,
**kwargs,
):
"""Initialize the Audio Context TTS service.
Args:
reuse_context_id_within_turn: Whether the service should reuse context IDs within the same turn.
reconnect_on_error: Whether to automatically reconnect on websocket errors.
**kwargs: Additional arguments passed to the parent WebsocketTTSService.
"""
super().__init__(reconnect_on_error=reconnect_on_error, **kwargs)
self._reuse_context_id_within_turn = reuse_context_id_within_turn
self._context_id = None
self._contexts: Dict[str, asyncio.Queue] = {}
self._audio_context_task = None
@@ -1098,6 +1106,10 @@ class AudioContextTTSService(WebsocketTTSService):
Args:
context_id: Unique identifier for the audio context.
"""
# Set the context ID if not already set
if not self._context_id:
self._context_id = context_id
await self._contexts_queue.put(context_id)
self._contexts[context_id] = asyncio.Queue()
logger.trace(f"{self} created audio context {context_id}")
@@ -1130,6 +1142,32 @@ class AudioContextTTSService(WebsocketTTSService):
else:
logger.warning(f"{self} unable to remove context {context_id}")
def has_active_audio_context(self) -> bool:
"""Check if there is an active audio context.
Returns:
True if an active audio context exists, False otherwise.
"""
return self._context_id is not None and self.audio_context_available(self._context_id)
def get_active_audio_context_id(self) -> Optional[str]:
"""Get the active audio context ID.
Returns:
The active context ID, or None if no context is active.
"""
return self._context_id
async def remove_active_audio_context(self):
"""Remove the active audio context."""
if self._context_id:
await self.remove_audio_context(self._context_id)
self.reset_active_audio_context()
def reset_active_audio_context(self):
"""Reset the active audio context."""
self._context_id = None
def audio_context_available(self, context_id: str) -> bool:
"""Check whether the given audio context is registered.
@@ -1141,6 +1179,26 @@ class AudioContextTTSService(WebsocketTTSService):
"""
return context_id in self._contexts
def create_context_id(self) -> str:
"""Generate or reuse a context ID based on concurrent TTS support.
If _reuse_context_id_within_turn is False and a context already exists,
the existing context ID is returned. Otherwise, a new unique context
ID is generated.
Returns:
A context ID string for the TTS request.
"""
if self._reuse_context_id_within_turn and self._context_id:
self._refresh_active_audio_context()
return self._context_id
return super().create_context_id()
def _refresh_active_audio_context(self):
"""Signal that the audio context is still in use, resetting the timeout."""
if self.has_active_audio_context():
self._contexts[self._context_id].put_nowait(AudioContextTTSService._CONTEXT_KEEPALIVE)
async def start(self, frame: StartFrame):
"""Start the audio context TTS service.
@@ -1176,6 +1234,7 @@ class AudioContextTTSService(WebsocketTTSService):
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction)
await self._stop_audio_context_task()
self.reset_active_audio_context()
self._create_audio_context_task()
def _create_audio_context_task(self):
@@ -1194,6 +1253,7 @@ class AudioContextTTSService(WebsocketTTSService):
running = True
while running:
context_id = await self._contexts_queue.get()
self._context_id = context_id
if context_id:
# Process the audio context until the context doesn't have more
@@ -1202,11 +1262,15 @@ class AudioContextTTSService(WebsocketTTSService):
# We just finished processing the context, so we can safely remove it.
del self._contexts[context_id]
self.reset_active_audio_context()
# Append some silence between sentences.
silence = b"\x00" * self.sample_rate
frame = TTSAudioRawFrame(
audio=silence, sample_rate=self.sample_rate, num_channels=1
audio=silence,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
await self.push_frame(frame)
else:
@@ -1222,6 +1286,10 @@ class AudioContextTTSService(WebsocketTTSService):
while running:
try:
frame = await asyncio.wait_for(queue.get(), timeout=AUDIO_CONTEXT_TIMEOUT)
if frame is AudioContextTTSService._CONTEXT_KEEPALIVE:
# Context is still in use, reset the timeout.
continue
if frame:
await self.push_frame(frame)
running = frame is not None

View File

@@ -237,6 +237,18 @@ class BaseOutputTransport(FrameProcessor):
else:
await self._write_dtmf_audio(frame)
async def write_transport_frame(self, frame: Frame):
"""Handle a queued frame after preceding audio has been sent.
Override in transport subclasses to handle custom frame types that
flow through the audio queue. Called by the media sender after the
frame has waited for any preceding audio to finish.
Args:
frame: The frame to handle.
"""
pass
def _supports_native_dtmf(self) -> bool:
"""Override in transport implementations that support native DTMF.
@@ -613,6 +625,11 @@ class BaseOutputTransport(FrameProcessor):
downstream_frame.transport_destination = self._destination
upstream_frame = BotStartedSpeakingFrame()
upstream_frame.transport_destination = self._destination
# Setting the siblings id
upstream_frame.broadcast_sibling_id = downstream_frame.id
downstream_frame.broadcast_sibling_id = upstream_frame.id
await self._transport.push_frame(downstream_frame)
await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM)
@@ -635,6 +652,11 @@ class BaseOutputTransport(FrameProcessor):
downstream_frame.transport_destination = self._destination
upstream_frame = BotStoppedSpeakingFrame()
upstream_frame.transport_destination = self._destination
# Setting the siblings id
upstream_frame.broadcast_sibling_id = downstream_frame.id
downstream_frame.broadcast_sibling_id = upstream_frame.id
await self._transport.push_frame(downstream_frame)
await self._transport.push_frame(upstream_frame, FrameDirection.UPSTREAM)
@@ -681,6 +703,8 @@ class BaseOutputTransport(FrameProcessor):
await self._transport.send_message(frame)
elif isinstance(frame, OutputDTMFFrame):
await self._transport.write_dtmf(frame)
else:
await self._transport.write_transport_frame(frame)
def _next_frame(self) -> AsyncGenerator[Frame, None]:
"""Generate the next frame for audio processing.

View File

@@ -15,7 +15,7 @@ import asyncio
import time
from concurrent.futures import CancelledError as FuturesCancelledError
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple
import aiohttp
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams
from pipecat.frames.frames import (
CancelFrame,
ControlFrame,
DataFrame,
EndFrame,
Frame,
InputAudioRawFrame,
@@ -183,34 +183,44 @@ class DailyInputTransportMessageUrgentFrame(DailyInputTransportMessageFrame):
@dataclass
class DailyUpdateRemoteParticipantsFrame(ControlFrame):
"""Frame to update remote participants in Daily calls.
class DailySIPTransferFrame(DataFrame):
"""SIP call transfer frame for transport queuing.
.. deprecated:: 0.0.87
`DailyUpdateRemoteParticipantsFrame` is deprecated and will be removed in a future version.
Create your own custom frame and use a custom processor to handle it or use, for example,
`on_after_push_frame` event instead in the output transport.
A SIP call transfer that will be queued. The transfer will happen after any
preceding audio finishes playing, allowing the bot to complete its current
utterance before the transfer occurs.
Parameters:
settings: SIP call transfer settings.
"""
settings: Mapping[str, Any] = field(default_factory=dict)
@dataclass
class DailySIPReferFrame(DataFrame):
"""SIP REFER frame for transport queuing.
A SIP REFER that will be queued. The REFER will happen after any preceding
audio finishes playing, allowing the bot to complete its current utterance
before the REFER occurs.
Parameters:
settings: SIP REFER settings.
"""
settings: Mapping[str, Any] = field(default_factory=dict)
@dataclass
class DailyUpdateRemoteParticipantsFrame(DataFrame):
"""Frame to update remote participants in Daily calls.
Parameters:
remote_participants: See https://reference-python.daily.co/api_reference.html#daily.CallClient.update_remote_participants.
"""
remote_participants: Mapping[str, Any] = None
def __post_init__(self):
super().__post_init__()
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"DailyUpdateRemoteParticipantsFrame is deprecated and will be removed in a future version."
"Instead, create your own custom frame and handle it in the "
'`@transport.output().event_handler("on_after_push_frame")` event handler or a '
"custom processor.",
DeprecationWarning,
stacklevel=2,
)
remote_participants: Mapping[str, Any] = field(default_factory=dict)
class WebRTCVADAnalyzer(VADAnalyzer):
@@ -501,6 +511,7 @@ class DailyTransportClient(EventHandler):
self._event_task = None
self._audio_task = None
self._video_task = None
self._join_message_queue: list = []
# Input and ouput sample rates. They will be initialize on setup().
self._in_sample_rate = 0
@@ -567,7 +578,8 @@ class DailyTransportClient(EventHandler):
error: An error description or None.
"""
if not self._joined:
return "Unable to send messages before joining."
self._join_message_queue.append(frame)
return None
participant_id = None
if isinstance(
@@ -768,6 +780,8 @@ class DailyTransportClient(EventHandler):
await self._callbacks.on_joined(data)
self._joined_event.set()
await self._flush_join_messages()
else:
error_msg = f"Error joining {self._room_url}: {error}"
logger.error(error_msg)
@@ -1541,6 +1555,12 @@ class DailyTransportClient(EventHandler):
await callback(*args)
queue.task_done()
async def _flush_join_messages(self):
"""Send any messages that were queued before join completed."""
for frame in self._join_message_queue:
await self.send_message(frame)
self._join_message_queue.clear()
def _get_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get the event loop from the task manager."""
if not self._task_manager:
@@ -1946,18 +1966,6 @@ class DailyOutputTransport(BaseOutputTransport):
# Leave the room.
await self._client.leave()
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process outgoing frames, including transport messages.
Args:
frame: The frame to process.
direction: The direction of frame flow in the pipeline.
"""
await super().process_frame(frame, direction)
if isinstance(frame, DailyUpdateRemoteParticipantsFrame):
await self._client.update_remote_participants(frame.remote_participants)
async def send_message(
self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
):
@@ -1968,7 +1976,7 @@ class DailyOutputTransport(BaseOutputTransport):
"""
error = await self._client.send_message(frame)
if error:
logger.error(f"Unable to send message: {error}")
await self.push_error(f"Unable to send message: {error}")
async def register_video_destination(self, destination: str):
"""Register a video output destination.
@@ -2011,6 +2019,25 @@ class DailyOutputTransport(BaseOutputTransport):
"""
return await self._client.write_video_frame(frame)
async def write_transport_frame(self, frame: Frame):
"""Handle queued SIP frames after preceding audio has been sent.
Args:
frame: The frame to handle.
"""
if isinstance(frame, DailySIPTransferFrame):
error = await self._client.sip_call_transfer(frame.settings)
if error:
await self.push_error(f"Unable to transfer SIP call: {error}")
elif isinstance(frame, DailySIPReferFrame):
error = await self._client.sip_refer(frame.settings)
if error:
await self.push_error(f"Unable to perform SIP REFER: {error}")
elif isinstance(frame, DailyUpdateRemoteParticipantsFrame):
error = await self._client.update_remote_participants(frame.remote_participants)
if error:
await self.push_error(f"Unable to update remote participants: {error}")
def _supports_native_dtmf(self) -> bool:
"""Daily supports native DTMF via telephone events.
@@ -2039,6 +2066,61 @@ class DailyTransport(BaseTransport):
Provides comprehensive Daily integration including audio/video streaming,
transcription, recording, dial-in/out functionality, and real-time communication
features for conversational AI applications.
Event handlers available:
- on_joined: Called when the bot joins the room. Args: (data: dict)
- on_left: Called when the bot leaves the room.
- on_before_leave: [sync] Called just before the bot leaves the room.
- on_error: Called when a transport error occurs. Args: (error: str)
- on_call_state_updated: Called when the call state changes. Args: (state: str)
- on_first_participant_joined: Called when the first participant joins.
Args: (participant: dict)
- on_participant_joined: Called when any participant joins.
Args: (participant: dict)
- on_participant_left: Called when a participant leaves.
Args: (participant: dict, reason: str)
- on_participant_updated: Called when a participant's state changes.
Args: (participant: dict)
- on_client_connected: Called when a participant connects (alias for
on_participant_joined). Args: (participant: dict)
- on_client_disconnected: Called when a participant disconnects (alias for
on_participant_left). Args: (participant: dict)
- on_active_speaker_changed: Called when the active speaker changes.
Args: (participant: dict)
- on_app_message: Called when an app message is received.
Args: (message: Any, sender: str)
- on_transcription_message: Called when a transcription message is received.
Args: (message: dict)
- on_recording_started: Called when recording starts. Args: (status: str)
- on_recording_stopped: Called when recording stops. Args: (stream_id: str)
- on_recording_error: Called when a recording error occurs.
Args: (stream_id: str, message: str)
- on_dialin_connected: Called when a dial-in call connects. Args: (data: dict)
- on_dialin_ready: Called when the SIP endpoint is ready.
Args: (sip_endpoint: str)
- on_dialin_stopped: Called when a dial-in call stops. Args: (data: dict)
- on_dialin_error: Called when a dial-in error occurs. Args: (data: dict)
- on_dialin_warning: Called when a dial-in warning occurs. Args: (data: dict)
- on_dialout_answered: Called when a dial-out call is answered. Args: (data: dict)
- on_dialout_connected: Called when a dial-out call connects. Args: (data: dict)
- on_dialout_stopped: Called when a dial-out call stops. Args: (data: dict)
- on_dialout_error: Called when a dial-out error occurs. Args: (data: dict)
- on_dialout_warning: Called when a dial-out warning occurs. Args: (data: dict)
Example::
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant):
await task.queue_frame(TTSSpeakFrame("Hello!"))
@transport.event_handler("on_participant_left")
async def on_participant_left(transport, participant, reason):
await task.queue_frame(EndFrame())
@transport.event_handler("on_app_message")
async def on_app_message(transport, message, sender):
logger.info(f"Message from {sender}: {message}")
"""
def __init__(

View File

@@ -289,6 +289,17 @@ class HeyGenTransport(BaseTransport):
When used, the Pipecat bot joins the same virtual room as the HeyGen Avatar and the user.
This is achieved by using `HeyGenTransport`, which initiates the conversation via
`HeyGenApi` and obtains a room URL that all participants connect to.
Event handlers available:
- on_client_connected(transport, participant): Participant connected to the session
- on_client_disconnected(transport, participant): Participant disconnected from the session
Example::
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, participant):
...
"""
def __init__(

View File

@@ -950,6 +950,41 @@ class LiveKitTransport(BaseTransport):
Provides comprehensive LiveKit integration including audio streaming, data
messaging, participant management, and room event handling for conversational
AI applications.
Event handlers available:
- on_connected: Called when the bot connects to the room.
- on_disconnected: Called when the bot disconnects from the room.
- on_before_disconnect: [sync] Called just before the bot disconnects.
- on_call_state_updated: Called when the call state changes. Args: (state: str)
- on_first_participant_joined: Called when the first participant joins.
Args: (participant_id: str)
- on_participant_connected: Called when a participant connects.
Args: (participant_id: str)
- on_participant_disconnected: Called when a participant disconnects.
Args: (participant_id: str)
- on_participant_left: Called when a participant leaves.
Args: (participant_id: str, reason: str)
- on_audio_track_subscribed: Called when an audio track is subscribed.
Args: (participant_id: str)
- on_audio_track_unsubscribed: Called when an audio track is unsubscribed.
Args: (participant_id: str)
- on_video_track_subscribed: Called when a video track is subscribed.
Args: (participant_id: str)
- on_video_track_unsubscribed: Called when a video track is unsubscribed.
Args: (participant_id: str)
- on_data_received: Called when data is received from a participant.
Args: (data: bytes, participant_id: str)
Example::
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant_id):
await task.queue_frame(TTSSpeakFrame("Hello!"))
@transport.event_handler("on_participant_disconnected")
async def on_participant_disconnected(transport, participant_id):
await task.queue_frame(EndFrame())
"""
def __init__(

View File

@@ -233,9 +233,8 @@ class SmallWebRTCClient:
self._out_sample_rate = None
self._leave_counter = 0
# We are always resampling it for 16000 if the sample_rate that we receive is bigger than that.
# otherwise we face issues with Silero VAD
self._pipecat_resampler = AudioResampler("s16", "mono", 16000)
# Audio resampler - will be configured during setup with target sample rate
self._audio_in_resampler = None
@self._webrtc_connection.event_handler("connected")
async def on_connected(connection: SmallWebRTCConnection):
@@ -375,32 +374,22 @@ class SmallWebRTCClient:
await asyncio.sleep(0.01)
continue
if frame.sample_rate > self._in_sample_rate:
resampled_frames = self._pipecat_resampler.resample(frame)
for resampled_frame in resampled_frames:
# 16-bit PCM bytes
pcm_array = resampled_frame.to_ndarray().astype(np.int16)
pcm_bytes = pcm_array.tobytes()
del pcm_array # free NumPy array immediately
# Resample if needed, otherwise use the frame as-is
frames_to_process = (
self._audio_in_resampler.resample(frame)
if frame.sample_rate != self._in_sample_rate
else [frame]
)
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=resampled_frame.sample_rate,
num_channels=self._audio_in_channels,
)
audio_frame.pts = frame.pts
del pcm_bytes # reference kept in audio_frame
yield audio_frame
else:
# 16-bit PCM bytes
pcm_array = frame.to_ndarray().astype(np.int16)
for processed_frame in frames_to_process:
# Convert to 16-bit PCM bytes
pcm_array = processed_frame.to_ndarray().astype(np.int16)
pcm_bytes = pcm_array.tobytes()
del pcm_array # free NumPy array immediately
audio_frame = InputAudioRawFrame(
audio=pcm_bytes,
sample_rate=frame.sample_rate,
sample_rate=self._in_sample_rate,
num_channels=self._audio_in_channels,
)
audio_frame.pts = frame.pts
@@ -450,6 +439,7 @@ class SmallWebRTCClient:
self._out_sample_rate = _params.audio_out_sample_rate or frame.audio_out_sample_rate
self._params = _params
self._leave_counter += 1
self._audio_in_resampler = AudioResampler("s16", "mono", self._in_sample_rate)
async def connect(self):
"""Establish the WebRTC connection."""
@@ -874,6 +864,18 @@ class SmallWebRTCTransport(BaseTransport):
Provides bidirectional audio and video streaming over WebRTC connections
with support for application messaging and connection event handling.
Event handlers available:
- on_client_connected(transport, client): Client connected to WebRTC session
- on_client_disconnected(transport, client): Client disconnected from WebRTC session
- on_client_message(transport, message, client): Received a data channel message
Example::
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
...
"""
def __init__(

View File

@@ -519,7 +519,7 @@ class TavusInputTransport(BaseInputTransport):
"""Handle received participant audio data."""
frame = InputAudioRawFrame(
audio=audio.audio_frames,
sample_rate=audio.audio_frames,
sample_rate=audio.sample_rate,
num_channels=audio.num_channels,
)
frame.transport_source = audio_source
@@ -661,6 +661,17 @@ class TavusTransport(BaseTransport):
When used, the Pipecat bot joins the same virtual room as the Tavus Avatar and the user.
This is achieved by using `TavusTransportClient`, which initiates the conversation via
`TavusApi` and obtains a room URL that all participants connect to.
Event handlers available:
- on_client_connected(transport, participant): Participant connected to the session
- on_client_disconnected(transport, participant): Participant disconnected from the session
Example::
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, participant):
...
"""
def __init__(

View File

@@ -471,6 +471,17 @@ class WebsocketClientTransport(BaseTransport):
Provides a complete WebSocket client transport implementation with
input and output capabilities, connection management, and event handling.
Event handlers available:
- on_connected(transport): Connected to WebSocket server
- on_disconnected(transport): Disconnected from WebSocket server
Example::
@transport.event_handler("on_connected")
async def on_connected(transport):
...
"""
def __init__(

View File

@@ -534,6 +534,18 @@ class FastAPIWebsocketTransport(BaseTransport):
Provides bidirectional WebSocket communication with frame serialization,
session management, and event handling for client connections and timeouts.
Event handlers available:
- on_client_connected(transport, websocket): Client WebSocket connected
- on_client_disconnected(transport, websocket): Client WebSocket disconnected
- on_session_timeout(transport, websocket): Session timed out
Example::
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, websocket):
...
"""
def __init__(

View File

@@ -421,6 +421,19 @@ class WebsocketServerTransport(BaseTransport):
Provides a complete WebSocket server implementation with separate input and
output transports, client connection management, and event handling for
real-time audio and data streaming applications.
Event handlers available:
- on_client_connected(transport, websocket): Client WebSocket connected
- on_client_disconnected(transport, websocket): Client WebSocket disconnected
- on_session_timeout(transport, websocket): Session timed out
- on_websocket_ready(transport): WebSocket server is ready to accept connections
Example::
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, websocket):
...
"""
def __init__(

View File

@@ -10,12 +10,15 @@ import asyncio
from typing import Optional
from pipecat.frames.frames import (
BotSpeakingFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
Frame,
FunctionCallCancelFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
UserSpeakingFrame,
UserIdleTimeoutUpdateFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.utils.asyncio.task_manager import BaseTaskManager
from pipecat.utils.base_object import BaseObject
@@ -25,14 +28,14 @@ class UserIdleController(BaseObject):
"""Controller for managing user idle detection.
This class monitors user activity and triggers an event when the user has been
idle (not speaking) for a configured timeout period. It only starts monitoring
after the first conversation activity and does not trigger while the bot is
speaking or function calls are in progress.
idle (not speaking) for a configured timeout period after the bot finishes
speaking. The timer starts when BotStoppedSpeakingFrame is received and is
cancelled when someone starts speaking again (UserStartedSpeakingFrame or
BotStartedSpeakingFrame).
The controller tracks activity using continuous frames (UserSpeakingFrame and
BotSpeakingFrame) which are emitted repeatedly while speaking is happening, and
state-based tracking for function calls (FunctionCallsStartedFrame and
FunctionCallResultFrame) which are only sent at start and end.
The timer is suppressed while a user turn is in progress to avoid false
triggers during interruptions (where BotStoppedSpeakingFrame arrives while
the user is still speaking).
Event handlers available:
@@ -49,12 +52,13 @@ class UserIdleController(BaseObject):
def __init__(
self,
*,
user_idle_timeout: float,
user_idle_timeout: float = 0,
):
"""Initialize the user idle controller.
Args:
user_idle_timeout: Timeout in seconds before considering the user idle.
0 disables idle detection.
"""
super().__init__()
@@ -62,11 +66,9 @@ class UserIdleController(BaseObject):
self._task_manager: Optional[BaseTaskManager] = None
self._conversation_started = False
self._function_call_in_progress = False
self.user_idle_event = asyncio.Event()
self.user_idle_task: Optional[asyncio.Task] = None
self._user_turn_in_progress: bool = False
self._function_calls_in_progress: int = 0
self._idle_timer_task: Optional[asyncio.Task] = None
self._register_event_handler("on_user_turn_idle", sync=True)
@@ -85,19 +87,10 @@ class UserIdleController(BaseObject):
"""
self._task_manager = task_manager
if not self.user_idle_task:
self.user_idle_task = self.task_manager.create_task(
self.user_idle_task_handler(),
f"{self}::user_idle_task_handler",
)
async def cleanup(self):
"""Cleanup the controller."""
await super().cleanup()
if self.user_idle_task:
await self.task_manager.cancel_task(self.user_idle_task)
self.user_idle_task = None
await self._cancel_idle_timer()
async def process_frame(self, frame: Frame):
"""Process an incoming frame to track user activity state.
@@ -105,69 +98,60 @@ class UserIdleController(BaseObject):
Args:
frame: The frame to be processed.
"""
# Start monitoring on first conversation activity
if not self._conversation_started:
if isinstance(frame, (UserStartedSpeakingFrame, BotSpeakingFrame)):
self._conversation_started = True
self.user_idle_event.set()
else:
return
if isinstance(frame, UserIdleTimeoutUpdateFrame):
self._user_idle_timeout = frame.timeout
if self._user_idle_timeout <= 0:
await self._cancel_idle_timer()
return
# Reset idle timer on continuous activity frames
if isinstance(frame, (UserSpeakingFrame, BotSpeakingFrame)):
await self._handle_activity(frame)
# Track function call state (start/end frames, not continuous)
if isinstance(frame, BotStoppedSpeakingFrame):
# Only start the timer if the user isn't mid-turn and no function
# calls are pending.
#
# Interruption case: the frame order is UserStartedSpeaking →
# BotStoppedSpeaking → (user keeps talking) → UserStoppedSpeaking.
# Without the user-turn guard the timer would start while the user
# is still speaking.
#
# Function call case: normally FunctionCallsStarted arrives after
# BotStoppedSpeaking and cancels the timer directly. But a race
# condition can cause FunctionCallsStarted to arrive before
# BotStoppedSpeaking when pushing a TTSSpeakFrame in the
# on_function_calls_started event handler, so the counter guard
# prevents the timer from starting while a function call is in progress.
if not self._user_turn_in_progress and self._function_calls_in_progress == 0:
await self._start_idle_timer()
elif isinstance(frame, BotStartedSpeakingFrame):
await self._cancel_idle_timer()
elif isinstance(frame, UserStartedSpeakingFrame):
self._user_turn_in_progress = True
await self._cancel_idle_timer()
elif isinstance(frame, UserStoppedSpeakingFrame):
self._user_turn_in_progress = False
elif isinstance(frame, FunctionCallsStartedFrame):
await self._handle_function_calls_started(frame)
elif isinstance(frame, FunctionCallResultFrame):
await self._handle_function_call_result(frame)
self._function_calls_in_progress += len(frame.function_calls)
await self._cancel_idle_timer()
elif isinstance(frame, (FunctionCallResultFrame, FunctionCallCancelFrame)):
self._function_calls_in_progress = max(0, self._function_calls_in_progress - 1)
async def _handle_activity(self, _: UserSpeakingFrame | BotSpeakingFrame):
"""Handle continuous activity frames that should reset the idle timer.
async def _start_idle_timer(self):
"""Start (or restart) the idle timer."""
if self._user_idle_timeout <= 0:
return
await self._cancel_idle_timer()
self._idle_timer_task = self.task_manager.create_task(
self._idle_timer_expired(),
f"{self}::idle_timer",
)
These frames are emitted continuously while the user or bot is speaking,
so we simply reset the timer whenever we receive them.
async def _cancel_idle_timer(self):
"""Cancel the idle timer if running."""
if self._idle_timer_task:
await self.task_manager.cancel_task(self._idle_timer_task)
self._idle_timer_task = None
Args:
frame: The activity frame to process.
"""
self.user_idle_event.set()
async def _handle_function_calls_started(self, _: FunctionCallsStartedFrame):
"""Handle function calls started event.
Function calls can take longer than the timeout, so we track their state
to prevent idle callbacks while they're in progress.
Args:
frame: The FunctionCallsStartedFrame to process.
"""
self._function_call_in_progress = True
self.user_idle_event.set()
async def _handle_function_call_result(self, _: FunctionCallResultFrame):
"""Handle function call result event.
Args:
frame: The FunctionCallResultFrame to process.
"""
self._function_call_in_progress = False
self.user_idle_event.set()
async def user_idle_task_handler(self):
"""Monitors for idle timeout and triggers events.
Runs in a loop until cancelled. The idle timer is reset whenever activity
frames are received (UserSpeakingFrame or BotSpeakingFrame). Function calls
are tracked via state since they only send start/end frames. If no activity
is detected for the configured timeout period and no function call is in
progress, the on_user_turn_idle event is triggered.
"""
while True:
try:
await asyncio.wait_for(self.user_idle_event.wait(), timeout=self._user_idle_timeout)
self.user_idle_event.clear()
except asyncio.TimeoutError:
# Only trigger if conversation has started and no function call is in progress
if self._conversation_started and not self._function_call_in_progress:
await self._call_event_handler("on_user_turn_idle")
async def _idle_timer_expired(self):
"""Sleep for the timeout duration then fire the idle event."""
await asyncio.sleep(self._user_idle_timeout)
self._idle_timer_task = None
await self._call_event_handler("on_user_turn_idle")

View File

@@ -66,7 +66,7 @@ class UserTurnProcessor(FrameProcessor):
*,
user_turn_strategies: Optional[UserTurnStrategies] = None,
user_turn_stop_timeout: float = 5.0,
user_idle_timeout: Optional[float] = None,
user_idle_timeout: float = 0,
**kwargs,
):
"""Initialize the user turn processor.
@@ -75,9 +75,9 @@ class UserTurnProcessor(FrameProcessor):
user_turn_strategies: Configured strategies for starting and stopping user turns.
user_turn_stop_timeout: Timeout in seconds to automatically stop a user turn
if no activity is detected.
user_idle_timeout: Optional timeout in seconds for detecting user idle state.
If set, the processor will emit an `on_user_turn_idle` event when the user
has been idle (not speaking) for this duration. Set to None to disable
user_idle_timeout: Timeout in seconds for detecting user idle state.
The processor will emit an `on_user_turn_idle` event when the user
has been idle (not speaking) for this duration. Set to 0 to disable
idle detection.
**kwargs: Additional keyword arguments.
"""
@@ -104,13 +104,8 @@ class UserTurnProcessor(FrameProcessor):
"on_user_turn_stop_timeout", self._on_user_turn_stop_timeout
)
# Optional user idle controller
self._user_idle_controller: Optional[UserIdleController] = None
if user_idle_timeout:
self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout)
self._user_idle_controller.add_event_handler(
"on_user_turn_idle", self._on_user_turn_idle
)
self._user_idle_controller = UserIdleController(user_idle_timeout=user_idle_timeout)
self._user_idle_controller.add_event_handler("on_user_turn_idle", self._on_user_turn_idle)
async def cleanup(self):
"""Clean up processor resources."""
@@ -149,14 +144,11 @@ class UserTurnProcessor(FrameProcessor):
await self._user_turn_controller.process_frame(frame)
if self._user_idle_controller:
await self._user_idle_controller.process_frame(frame)
await self._user_idle_controller.process_frame(frame)
async def _start(self, frame: StartFrame):
await self._user_turn_controller.setup(self.task_manager)
if self._user_idle_controller:
await self._user_idle_controller.setup(self.task_manager)
await self._user_idle_controller.setup(self.task_manager)
async def _stop(self, frame: EndFrame):
await self._cleanup()
@@ -166,9 +158,7 @@ class UserTurnProcessor(FrameProcessor):
async def _cleanup(self):
await self._user_turn_controller.cleanup()
if self._user_idle_controller:
await self._user_idle_controller.cleanup()
await self._user_idle_controller.cleanup()
async def _on_push_frame(
self, controller, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM
@@ -189,6 +179,8 @@ class UserTurnProcessor(FrameProcessor):
if params.enable_user_speaking_frames:
await self.broadcast_frame(UserStartedSpeakingFrame)
await self._user_idle_controller.process_frame(UserStartedSpeakingFrame())
if params.enable_interruptions and self._allow_interruptions:
await self.push_interruption_task_frame_and_wait()
@@ -205,6 +197,8 @@ class UserTurnProcessor(FrameProcessor):
if params.enable_user_speaking_frames:
await self.broadcast_frame(UserStoppedSpeakingFrame)
await self._user_idle_controller.process_frame(UserStoppedSpeakingFrame())
await self._call_event_handler("on_user_turn_stopped", strategy)
async def _on_user_turn_stop_timeout(self, controller):

View File

@@ -7,6 +7,11 @@
"""Base OpenTelemetry tracing decorators and utilities for Pipecat.
.. deprecated:: 0.0.103
This module is unused and will be removed in a future release.
Service tracing is handled by the decorators in
:mod:`pipecat.utils.tracing.service_decorators`.
This module provides class and method level tracing capabilities
similar to the original NVIDIA implementation.
"""
@@ -16,8 +21,16 @@ import contextlib
import enum
import functools
import inspect
import warnings
from typing import Callable, Optional, TypeVar
warnings.warn(
"pipecat.utils.tracing.class_decorators is deprecated and will be removed in a future "
"release. Use pipecat.utils.tracing.service_decorators instead.",
DeprecationWarning,
stacklevel=2,
)
from pipecat.utils.tracing.setup import is_tracing_available
# Import OpenTelemetry if available

View File

@@ -1,114 +0,0 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Conversation context provider for OpenTelemetry tracing in Pipecat.
This module provides a singleton context provider that manages the current
conversation's tracing context, allowing services to create child spans
that are properly associated with the conversation.
"""
import uuid
from typing import TYPE_CHECKING, Optional
# Import types for type checking only
if TYPE_CHECKING:
from opentelemetry.context import Context
from opentelemetry.trace import SpanContext
from pipecat.utils.tracing.setup import is_tracing_available
if is_tracing_available():
from opentelemetry.context import Context
from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context
class ConversationContextProvider:
"""Provides access to the current conversation's tracing context.
This is a singleton that can be used to get the current conversation's
span context to create child spans (like turns).
"""
_instance = None
_current_conversation_context: Optional["Context"] = None
_conversation_id: Optional[str] = None
@classmethod
def get_instance(cls):
"""Get the singleton instance.
Returns:
The singleton ConversationContextProvider instance.
"""
if cls._instance is None:
cls._instance = ConversationContextProvider()
return cls._instance
def set_current_conversation_context(
self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None
):
"""Set the current conversation context.
Args:
span_context: The span context for the current conversation or None to clear it.
conversation_id: Optional ID for the conversation.
"""
if not is_tracing_available():
return
self._conversation_id = conversation_id
if span_context:
# Create a non-recording span from the span context
non_recording_span = NonRecordingSpan(span_context)
self._current_conversation_context = set_span_in_context(non_recording_span)
else:
self._current_conversation_context = None
def get_current_conversation_context(self) -> Optional["Context"]:
"""Get the OpenTelemetry context for the current conversation.
Returns:
The current conversation context or None if not available.
"""
return self._current_conversation_context
def get_conversation_id(self) -> Optional[str]:
"""Get the ID for the current conversation.
Returns:
The current conversation ID or None if not available.
"""
return self._conversation_id
def generate_conversation_id(self) -> str:
"""Generate a new conversation ID.
Returns:
A new randomly generated UUID string.
"""
return str(uuid.uuid4())
def get_current_conversation_context() -> Optional["Context"]:
"""Get the OpenTelemetry context for the current conversation.
Returns:
The current conversation context or None if not available.
"""
provider = ConversationContextProvider.get_instance()
return provider.get_current_conversation_context()
def get_conversation_id() -> Optional[str]:
"""Get the ID for the current conversation.
Returns:
The current conversation ID or None if not available.
"""
provider = ConversationContextProvider.get_instance()
return provider.get_conversation_id()

View File

@@ -25,7 +25,6 @@ if TYPE_CHECKING:
from pipecat.processors.aggregators.llm_context import NOT_GIVEN, LLMContext
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.utils.tracing.conversation_context_provider import get_current_conversation_context
from pipecat.utils.tracing.service_attributes import (
add_gemini_live_span_attributes,
add_llm_span_attributes,
@@ -34,7 +33,6 @@ from pipecat.utils.tracing.service_attributes import (
add_tts_span_attributes,
)
from pipecat.utils.tracing.setup import is_tracing_available
from pipecat.utils.tracing.turn_context_provider import get_current_turn_context
if is_tracing_available():
from opentelemetry import context as context_api
@@ -73,6 +71,19 @@ def _noop_decorator(func):
return func
def _get_turn_context(self):
"""Get the current turn's tracing context if available.
Args:
self: The service instance.
Returns:
The turn context, or None if unavailable.
"""
tracing_ctx = getattr(self, "_tracing_context", None)
return tracing_ctx.get_turn_context() if tracing_ctx else None
def _get_parent_service_context(self):
"""Get the parent service span context (internal use only).
@@ -88,12 +99,14 @@ def _get_parent_service_context(self):
if not is_tracing_available():
return None
# The parent span was created when Traceable was initialized and stored as self._span
# TODO: Remove this block and delete class_decorators.py once Traceable is removed.
# Legacy: support for classes inheriting from Traceable (currently unused, deprecated).
if hasattr(self, "_span") and self._span:
return trace.set_span_in_context(self._span)
# Fall back to conversation context if available
conversation_context = get_current_conversation_context()
# Use the conversation context set by TurnTraceObserver via TracingContext.
tracing_ctx = getattr(self, "_tracing_context", None)
conversation_context = tracing_ctx.get_conversation_context() if tracing_ctx else None
if conversation_context:
return conversation_context
@@ -200,8 +213,7 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) -
span_name = "tts"
# Get parent context
turn_context = get_current_turn_context()
parent_context = turn_context or _get_parent_service_context(self)
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
# Create span
tracer = trace.get_tracer("pipecat")
@@ -236,19 +248,21 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) -
@functools.wraps(f)
async def gen_wrapper(self, text, *args, **kwargs):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
async for item in f(self, text, *args, **kwargs):
yield item
return
if not getattr(self, "_tracing_enabled", False):
async for item in f(self, text, *args, **kwargs):
yield item
return
fn_called = False
try:
async with tracing_context(self, text):
fn_called = True
async for item in f(self, text, *args, **kwargs):
yield item
except Exception as e:
if fn_called:
raise
logging.error(f"Error in TTS tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
async for item in f(self, text, *args, **kwargs):
yield item
@@ -257,16 +271,18 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) -
@functools.wraps(f)
async def wrapper(self, text, *args, **kwargs):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
return await f(self, text, *args, **kwargs)
if not getattr(self, "_tracing_enabled", False):
return await f(self, text, *args, **kwargs)
fn_called = False
try:
async with tracing_context(self, text):
fn_called = True
return await f(self, text, *args, **kwargs)
except Exception as e:
if fn_called:
raise
logging.error(f"Error in TTS tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
return await f(self, text, *args, **kwargs)
return wrapper
@@ -299,17 +315,16 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) -
def decorator(f):
@functools.wraps(f)
async def wrapper(self, transcript, is_final, language=None):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
return await f(self, transcript, is_final, language)
if not getattr(self, "_tracing_enabled", False):
return await f(self, transcript, is_final, language)
fn_called = False
try:
service_class_name = self.__class__.__name__
span_name = "stt"
# Get the turn context first, then fall back to service context
turn_context = get_current_turn_context()
parent_context = turn_context or _get_parent_service_context(self)
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
# Create a new span as child of the turn span or service span
tracer = trace.get_tracer("pipecat")
@@ -339,14 +354,16 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) -
)
# Call the original function
fn_called = True
return await f(self, transcript, is_final, language)
except Exception as e:
# Log any exception but don't disrupt the main flow
logging.warning(f"Error in STT transcription tracing: {e}")
raise
except Exception as e:
if fn_called:
raise
logging.error(f"Error in STT tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
return await f(self, transcript, is_final, language)
return wrapper
@@ -381,17 +398,16 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) -
def decorator(f):
@functools.wraps(f)
async def wrapper(self, context, *args, **kwargs):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
return await f(self, context, *args, **kwargs)
if not getattr(self, "_tracing_enabled", False):
return await f(self, context, *args, **kwargs)
fn_called = False
try:
service_class_name = self.__class__.__name__
span_name = "llm"
# Get the parent context - turn context if available, otherwise service context
turn_context = get_current_turn_context()
parent_context = turn_context or _get_parent_service_context(self)
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
# Create a new span as child of the turn span or service span
tracer = trace.get_tracer("pipecat")
@@ -530,6 +546,7 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) -
# Don't raise - let the function execute anyway
# Run function with modified push_frame to capture the output
fn_called = True
result = await f(self, context, *args, **kwargs)
# Add aggregated output after function completes, if available
@@ -555,8 +572,9 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) -
if ttfb is not None:
current_span.set_attribute("metrics.ttfb", ttfb)
except Exception as e:
if fn_called:
raise
logging.error(f"Error in LLM tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
return await f(self, context, *args, **kwargs)
return wrapper
@@ -588,17 +606,16 @@ def traced_gemini_live(operation: str) -> Callable:
def decorator(func):
@functools.wraps(func)
async def wrapper(self, *args, **kwargs):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
return await func(self, *args, **kwargs)
if not getattr(self, "_tracing_enabled", False):
return await func(self, *args, **kwargs)
fn_called = False
try:
service_class_name = self.__class__.__name__
span_name = f"{operation}"
# Get the parent context - turn context if available, otherwise service context
turn_context = get_current_turn_context()
parent_context = turn_context or _get_parent_service_context(self)
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
# Create a new span as child of the turn span or service span
tracer = trace.get_tracer("pipecat")
@@ -851,6 +868,7 @@ def traced_gemini_live(operation: str) -> Callable:
current_span.set_attribute("metrics.ttfb", ttfb)
# Run the original function
fn_called = True
result = await func(self, *args, **kwargs)
return result
@@ -861,8 +879,9 @@ def traced_gemini_live(operation: str) -> Callable:
raise
except Exception as e:
if fn_called:
raise
logging.error(f"Error in Gemini Live tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
return await func(self, *args, **kwargs)
return wrapper
@@ -891,17 +910,16 @@ def traced_openai_realtime(operation: str) -> Callable:
def decorator(func):
@functools.wraps(func)
async def wrapper(self, *args, **kwargs):
try:
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
return await func(self, *args, **kwargs)
if not getattr(self, "_tracing_enabled", False):
return await func(self, *args, **kwargs)
fn_called = False
try:
service_class_name = self.__class__.__name__
span_name = f"{operation}"
# Get the parent context - turn context if available, otherwise service context
turn_context = get_current_turn_context()
parent_context = turn_context or _get_parent_service_context(self)
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
# Create a new span as child of the turn span or service span
tracer = trace.get_tracer("pipecat")
@@ -1071,6 +1089,7 @@ def traced_openai_realtime(operation: str) -> Callable:
current_span.set_attribute("metrics.ttfb", ttfb)
# Run the original function
fn_called = True
result = await func(self, *args, **kwargs)
return result
@@ -1081,8 +1100,9 @@ def traced_openai_realtime(operation: str) -> Callable:
raise
except Exception as e:
if fn_called:
raise
logging.error(f"Error in OpenAI Realtime tracing (continuing without tracing): {e}")
# If tracing fails, fall back to the original function
return await func(self, *args, **kwargs)
return wrapper

View File

@@ -0,0 +1,109 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Pipeline-scoped tracing context for OpenTelemetry tracing in Pipecat.
This module provides a per-pipeline tracing context that holds the current
conversation and turn span contexts. Each PipelineTask creates its own
TracingContext, ensuring concurrent pipelines do not interfere with each other.
"""
import uuid
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from opentelemetry.context import Context
from opentelemetry.trace import SpanContext
from pipecat.utils.tracing.setup import is_tracing_available
if is_tracing_available():
from opentelemetry.context import Context
from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context
class TracingContext:
"""Pipeline-scoped tracing context.
Holds the current conversation and turn span contexts for a single pipeline.
Created by PipelineTask, passed to TurnTraceObserver (writer) and services
(readers) via StartFrame.
"""
def __init__(self):
"""Initialize the tracing context with empty state."""
self._conversation_context: Optional["Context"] = None
self._turn_context: Optional["Context"] = None
self._conversation_id: Optional[str] = None
def set_conversation_context(
self, span_context: Optional["SpanContext"], conversation_id: Optional[str] = None
):
"""Set the current conversation context.
Args:
span_context: The span context for the current conversation or None to clear it.
conversation_id: Optional ID for the conversation.
"""
if not is_tracing_available():
return
self._conversation_id = conversation_id
if span_context:
non_recording_span = NonRecordingSpan(span_context)
self._conversation_context = set_span_in_context(non_recording_span)
else:
self._conversation_context = None
def get_conversation_context(self) -> Optional["Context"]:
"""Get the OpenTelemetry context for the current conversation.
Returns:
The current conversation context or None if not available.
"""
return self._conversation_context
def set_turn_context(self, span_context: Optional["SpanContext"]):
"""Set the current turn context.
Args:
span_context: The span context for the current turn or None to clear it.
"""
if not is_tracing_available():
return
if span_context:
non_recording_span = NonRecordingSpan(span_context)
self._turn_context = set_span_in_context(non_recording_span)
else:
self._turn_context = None
def get_turn_context(self) -> Optional["Context"]:
"""Get the OpenTelemetry context for the current turn.
Returns:
The current turn context or None if not available.
"""
return self._turn_context
@property
def conversation_id(self) -> Optional[str]:
"""Get the ID for the current conversation.
Returns:
The current conversation ID or None if not available.
"""
return self._conversation_id
@staticmethod
def generate_conversation_id() -> str:
"""Generate a new conversation ID.
Returns:
A new randomly generated UUID string.
"""
return str(uuid.uuid4())

View File

@@ -1,81 +0,0 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Turn context provider for OpenTelemetry tracing in Pipecat.
This module provides a singleton context provider that manages the current
turn's tracing context, allowing services to create child spans that are
properly associated with the conversation turn.
"""
from typing import TYPE_CHECKING, Optional
# Import types for type checking only
if TYPE_CHECKING:
from opentelemetry.context import Context
from opentelemetry.trace import SpanContext
from pipecat.utils.tracing.setup import is_tracing_available
if is_tracing_available():
from opentelemetry.context import Context
from opentelemetry.trace import NonRecordingSpan, SpanContext, set_span_in_context
class TurnContextProvider:
"""Provides access to the current turn's tracing context.
This is a singleton that services can use to get the current turn's
span context to create child spans.
"""
_instance = None
_current_turn_context: Optional["Context"] = None
@classmethod
def get_instance(cls):
"""Get the singleton instance.
Returns:
The singleton TurnContextProvider instance.
"""
if cls._instance is None:
cls._instance = TurnContextProvider()
return cls._instance
def set_current_turn_context(self, span_context: Optional["SpanContext"]):
"""Set the current turn context.
Args:
span_context: The span context for the current turn or None to clear it.
"""
if not is_tracing_available():
return
if span_context:
# Create a non-recording span from the span context
non_recording_span = NonRecordingSpan(span_context)
self._current_turn_context = set_span_in_context(non_recording_span)
else:
self._current_turn_context = None
def get_current_turn_context(self) -> Optional["Context"]:
"""Get the OpenTelemetry context for the current turn.
Returns:
The current turn context or None if not available.
"""
return self._current_turn_context
def get_current_turn_context() -> Optional["Context"]:
"""Get the OpenTelemetry context for the current turn.
Returns:
The current turn context or None if not available.
"""
provider = TurnContextProvider.get_instance()
return provider.get_current_turn_context()

View File

@@ -19,9 +19,8 @@ from pipecat.frames.frames import StartFrame
from pipecat.observers.base_observer import BaseObserver, FramePushed
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
from pipecat.utils.tracing.conversation_context_provider import ConversationContextProvider
from pipecat.utils.tracing.setup import is_tracing_available
from pipecat.utils.tracing.turn_context_provider import TurnContextProvider
from pipecat.utils.tracing.tracing_context import TracingContext
# Import types for type checking only
if TYPE_CHECKING:
@@ -49,6 +48,7 @@ class TurnTraceObserver(BaseObserver):
latency_tracker: UserBotLatencyObserver,
conversation_id: Optional[str] = None,
additional_span_attributes: Optional[dict] = None,
tracing_context: Optional[TracingContext] = None,
**kwargs,
):
"""Initialize the turn trace observer.
@@ -58,11 +58,13 @@ class TurnTraceObserver(BaseObserver):
latency_tracker: The latency tracking observer for user-bot latency.
conversation_id: Optional conversation ID for grouping turns.
additional_span_attributes: Additional attributes to add to spans.
tracing_context: Pipeline-scoped tracing context for span hierarchy.
**kwargs: Additional arguments passed to parent class.
"""
super().__init__(**kwargs)
self._turn_tracker = turn_tracker
self._latency_tracker = latency_tracker
self._tracing_context = tracing_context or TracingContext()
self._current_span: Optional["Span"] = None
self._current_turn_number: int = 0
self._trace_context_map: Dict[int, "SpanContext"] = {}
@@ -123,9 +125,8 @@ class TurnTraceObserver(BaseObserver):
return
# Generate a conversation ID if not provided
context_provider = ConversationContextProvider.get_instance()
if conversation_id is None:
conversation_id = context_provider.generate_conversation_id()
conversation_id = TracingContext.generate_conversation_id()
logger.debug(f"Generated new conversation ID: {conversation_id}")
self._conversation_id = conversation_id
@@ -140,8 +141,8 @@ class TurnTraceObserver(BaseObserver):
for k, v in (self._additional_span_attributes or {}).items():
self._conversation_span.set_attribute(k, v)
# Update the conversation context provider
context_provider.set_current_conversation_context(
# Update the tracing context
self._tracing_context.set_conversation_context(
self._conversation_span.get_span_context(), conversation_id
)
@@ -161,9 +162,8 @@ class TurnTraceObserver(BaseObserver):
self._current_span.end()
self._current_span = None
# Clear the turn context provider
context_provider = TurnContextProvider.get_instance()
context_provider.set_current_turn_context(None)
# Clear the turn context
self._tracing_context.set_turn_context(None)
# Now end the conversation span if it exists
if self._conversation_span:
@@ -171,9 +171,8 @@ class TurnTraceObserver(BaseObserver):
self._conversation_span.end()
self._conversation_span = None
# Clear the context provider
context_provider = ConversationContextProvider.get_instance()
context_provider.set_current_conversation_context(None)
# Clear the conversation context
self._tracing_context.set_conversation_context(None)
logger.debug(f"Ended tracing for Conversation {self._conversation_id}")
self._conversation_id = None
@@ -189,8 +188,7 @@ class TurnTraceObserver(BaseObserver):
# Get the parent context - conversation if available, otherwise use root context
parent_context = None
if self._conversation_span:
context_provider = ConversationContextProvider.get_instance()
parent_context = context_provider.get_current_conversation_context()
parent_context = self._tracing_context.get_conversation_context()
# Create a new span for this turn
self._current_span = self._tracer.start_span("turn", context=parent_context)
@@ -207,9 +205,8 @@ class TurnTraceObserver(BaseObserver):
# Store the span context so services can become children of this span
self._trace_context_map[turn_number] = self._current_span.get_span_context()
# Update the context provider so services can access this span
context_provider = TurnContextProvider.get_instance()
context_provider.set_current_turn_context(self._current_span.get_span_context())
# Update the tracing context so services can access this span
self._tracing_context.set_turn_context(self._current_span.get_span_context())
logger.debug(f"Started tracing for Turn {turn_number}")
@@ -228,9 +225,8 @@ class TurnTraceObserver(BaseObserver):
self._current_span.end()
self._current_span = None
# Clear the context provider
context_provider = TurnContextProvider.get_instance()
context_provider.set_current_turn_context(None)
# Clear the turn context
self._tracing_context.set_turn_context(None)
logger.debug(f"Ended tracing for Turn {turn_number}")