Merge branch 'main' into filipi/includes_inter_frame_spaces

# Conflicts:
#	uv.lock
This commit is contained in:
filipi87
2026-04-22 15:22:06 -03:00
374 changed files with 3688 additions and 1372 deletions

View File

@@ -15,9 +15,11 @@ from loguru import logger
try:
import krisp_audio
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use the Krisp instance, you need to install krisp_audio.")
raise Exception(f"Missing module: {e}")
raise ImportError(
"krisp_audio is required for Krisp audio features. "
"Install it to use KrispVivaFilter, KrispVivaVadAnalyzer, "
"KrispVivaTurn, or KrispVivaIPUserTurnStartStrategy."
) from e
# Mapping of sample rates (Hz) to Krisp SDK SamplingRate enums

View File

@@ -7,7 +7,9 @@
"""Krisp turn analyzer for end-of-turn detection using Krisp VIVA SDK.
This module provides a turn analyzer implementation using Krisp's turn detection
(Tt) API to determine when a user has finished speaking in a conversation.
v3 (Tt) API to determine when a user has finished speaking in a conversation.
The Tt API accepts an external VAD flag alongside audio frames, allowing the
model to leverage voice activity information for more accurate turn detection.
Note: This analyzer uses a different model than KrispVivaFilter. The model path
can be specified via the KRISP_VIVA_TURN_MODEL_PATH environment variable or
@@ -33,7 +35,7 @@ try:
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use KrispVivaTurn, you need to install krisp_audio.")
raise Exception(f"Missing module: {e}")
raise ImportError(f"Missing module: {e}") from e
class KrispTurnParams(BaseTurnParams):
@@ -53,8 +55,10 @@ class KrispTurnParams(BaseTurnParams):
class KrispVivaTurn(BaseTurnAnalyzer):
"""Turn analyzer using Krisp VIVA SDK for end-of-turn detection.
Uses Krisp's turn detection (Tt) API to determine when a user has finished
speaking. This analyzer requires a valid Krisp model file to operate.
Uses Krisp's turn detection v3 (Tt) API to determine when a user has
finished speaking. The Tt API receives an external VAD flag with each
audio frame, which the ``is_speech`` parameter of ``append_audio``
provides. This analyzer requires a valid Krisp model file to operate.
"""
def __init__(
@@ -158,14 +162,14 @@ class KrispVivaTurn(BaseTurnAnalyzer):
"""Create a turn detection session with the specified sample rate.
Args:
sample_rate: Sample rate for the session
sample_rate: Sample rate for the session.
Returns:
krisp_audio.TtFloat instance
krisp_audio.TtFloat instance.
Raises:
ValueError: If sample rate or frame duration is not supported
RuntimeError: If session creation fails
ValueError: If sample rate or frame duration is not supported.
RuntimeError: If session creation fails.
"""
try:
model_info = krisp_audio.ModelInfo()
@@ -306,12 +310,7 @@ class KrispVivaTurn(BaseTurnAnalyzer):
# Instead, we wait for the model's probability check below to confirm
# end-of-turn based on the threshold.
prob = self._tt_session.process(frame.tolist())
# Negative values indicate the model is not ready yet (working with 100ms data)
# Skip processing until we get positive probabilities
if prob < 0:
continue
prob = self._tt_session.process(frame.tolist(), is_speech, False)
# Store the probability for external access
self._last_probability = prob

View File

@@ -30,6 +30,7 @@ from pipecat.frames.frames import (
VADParamsUpdateFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.llm_context import LLMContextMessage
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import LLMService
from pipecat.utils.text.pattern_pair_aggregator import (
@@ -87,7 +88,7 @@ class IVRProcessor(FrameProcessor):
self._classifier_prompt = classifier_prompt
# Store saved context messages
self._saved_messages: list[dict] = []
self._saved_messages: list[LLMContextMessage] = []
# XML pattern aggregation
self._aggregator = PatternPairAggregator()
@@ -97,18 +98,18 @@ class IVRProcessor(FrameProcessor):
self._register_event_handler("on_conversation_detected")
self._register_event_handler("on_ivr_status_changed")
def update_saved_messages(self, messages: list[dict]) -> None:
def update_saved_messages(self, messages: list[LLMContextMessage]) -> None:
"""Update the saved context messages.
Sets the messages that are saved when switching between
conversation and IVR navigation modes.
Args:
messages: List of message dictionaries to save.
messages: List of context messages to save.
"""
self._saved_messages = messages
def _get_conversation_history(self) -> list[dict]:
def _get_conversation_history(self) -> list[LLMContextMessage]:
"""Get saved context messages without the system message.
Returns:
@@ -144,7 +145,9 @@ class IVRProcessor(FrameProcessor):
await self.push_frame(frame, direction)
# Set the classifier prompt and push it upstream
messages = [{"role": "system", "content": self._classifier_prompt}]
messages: list[LLMContextMessage] = [
{"role": "developer", "content": self._classifier_prompt}
]
llm_update_frame = LLMMessagesUpdateFrame(messages=messages)
await self.push_frame(llm_update_frame, FrameDirection.UPSTREAM)
@@ -261,7 +264,7 @@ class IVRProcessor(FrameProcessor):
logger.debug("IVR detected - switching to IVR navigation mode")
# Create new context with IVR system prompt and saved messages
messages = [{"role": "system", "content": self._ivr_prompt}]
messages: list[LLMContextMessage] = [{"role": "developer", "content": self._ivr_prompt}]
# Add saved conversation history if available
conversation_history = self._get_conversation_history()

View File

@@ -35,7 +35,7 @@ from pipecat.frames.frames import (
UserStoppedSpeakingFrame,
)
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_context import LLMContext, LLMContextMessage
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
@@ -617,9 +617,9 @@ VOICEMAIL SYSTEM (respond "VOICEMAIL"):
self._validate_prompt(custom_system_prompt)
# Set up the LLM context with the classification prompt
self._messages = [
self._messages: list[LLMContextMessage] = [
{
"role": "system",
"role": "developer",
"content": self._prompt,
},
]

View File

@@ -20,7 +20,6 @@ from typing import (
TYPE_CHECKING,
Any,
Literal,
Optional,
)
from pipecat.adapters.schemas.tools_schema import ToolsSchema
@@ -559,11 +558,11 @@ class LLMMessagesAppendFrame(DataFrame):
current context.
Parameters:
messages: List of message dictionaries to append.
messages: List of context messages to append.
run_llm: Whether the context update should be sent to the LLM.
"""
messages: list[dict]
messages: list[LLMContextMessage]
run_llm: bool | None = None
@@ -575,11 +574,11 @@ class LLMMessagesUpdateFrame(DataFrame):
context LLM messages.
Parameters:
messages: List of message dictionaries to replace current context.
messages: List of context messages to replace current context.
run_llm: Whether the context update should be sent to the LLM.
"""
messages: list[dict]
messages: list[LLMContextMessage]
run_llm: bool | None = None

View File

@@ -49,6 +49,15 @@ from pipecat.processors.frame_processor import FrameProcessor
_INTERNAL_TYPES = (PipelineSource, BasePipeline)
@dataclass
class _StartFrameInfo:
"""Captured once when the first StartFrame arrives at a processor."""
frame_id: int
arrival_ns: int
wall_clock: float
@dataclass
class _ArrivalInfo:
"""Internal record of when a StartFrame arrived at a processor."""
@@ -175,8 +184,8 @@ class StartupTimingObserver(BaseObserver):
# Collected timings in pipeline order.
self._timings: list[ProcessorStartupTiming] = []
# Lock onto the first StartFrame we see (by frame ID).
self._start_frame_id: str | None = None
# Captured once when the first StartFrame arrives.
self._start_frame: _StartFrameInfo | None = None
# Whether we've already emitted the startup timing report.
self._startup_timing_reported = False
@@ -184,15 +193,9 @@ class StartupTimingObserver(BaseObserver):
# Whether we've already measured transport timing.
self._transport_timing_reported = False
# Timestamp (ns) when we first see a StartFrame arrive at a processor.
self._start_frame_arrival_ns: int | None = None
# Bot connected timing (stored for inclusion in the transport report).
self._bot_connected_secs: float | None = None
# Wall clock time when the StartFrame was first seen.
self._start_wall_clock: float | None = None
self._register_event_handler("on_startup_timing_report")
self._register_event_handler("on_transport_timing_report")
@@ -233,11 +236,13 @@ class StartupTimingObserver(BaseObserver):
return
# Lock onto the first StartFrame.
if self._start_frame_id is None:
self._start_frame_id = data.frame.id
self._start_frame_arrival_ns = data.timestamp
self._start_wall_clock = time.time()
elif data.frame.id != self._start_frame_id:
if self._start_frame is None:
self._start_frame = _StartFrameInfo(
frame_id=data.frame.id,
arrival_ns=data.timestamp,
wall_clock=time.time(),
)
elif data.frame.id != self._start_frame.frame_id:
return
if self._should_track(data.processor):
@@ -268,16 +273,16 @@ class StartupTimingObserver(BaseObserver):
if not isinstance(data.frame, StartFrame):
return
if self._start_frame_id is not None and data.frame.id != self._start_frame_id:
if self._start_frame is not None and data.frame.id != self._start_frame.frame_id:
return
arrival = self._arrivals.pop(data.source.id, None)
if arrival is None:
if arrival is None or self._start_frame is None:
return
duration_ns = data.timestamp - arrival.arrival_ts_ns
duration_secs = duration_ns / 1e9
start_offset_secs = (arrival.arrival_ts_ns - self._start_frame_arrival_ns) / 1e9
start_offset_secs = (arrival.arrival_ts_ns - self._start_frame.arrival_ns) / 1e9
self._timings.append(
ProcessorStartupTiming(
@@ -289,22 +294,22 @@ class StartupTimingObserver(BaseObserver):
def _handle_bot_connected(self, data: FramePushed):
"""Record bot connected timing on first BotConnectedFrame."""
if self._bot_connected_secs is not None or self._start_frame_arrival_ns is None:
if self._bot_connected_secs is not None or self._start_frame is None:
return
delta_ns = data.timestamp - self._start_frame_arrival_ns
delta_ns = data.timestamp - self._start_frame.arrival_ns
self._bot_connected_secs = delta_ns / 1e9
async def _handle_client_connected(self, data: FramePushed):
"""Emit transport timing report on first ClientConnectedFrame."""
if self._transport_timing_reported or self._start_frame_arrival_ns is None:
if self._transport_timing_reported or self._start_frame is None:
return
self._transport_timing_reported = True
delta_ns = data.timestamp - self._start_frame_arrival_ns
delta_ns = data.timestamp - self._start_frame.arrival_ns
client_connected_secs = delta_ns / 1e9
report = TransportTimingReport(
start_time=self._start_wall_clock or 0.0,
start_time=self._start_frame.wall_clock,
bot_connected_secs=self._bot_connected_secs,
client_connected_secs=client_connected_secs,
)
@@ -319,7 +324,7 @@ class StartupTimingObserver(BaseObserver):
total = sum(t.duration_secs for t in self._timings)
report = StartupTimingReport(
start_time=self._start_wall_clock or 0.0,
start_time=self._start_frame.wall_clock if self._start_frame else 0.0,
total_duration_secs=total,
processor_timings=self._timings,
)

View File

@@ -6,7 +6,7 @@
"""LLM switcher for switching between different LLMs at runtime, with different switching strategies."""
from typing import Any
from typing import Any, cast
from pipecat.adapters.schemas.direct_function import DirectFunction
from pipecat.pipeline.service_switcher import (
@@ -15,6 +15,7 @@ from pipecat.pipeline.service_switcher import (
StrategyType,
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import LLMService
@@ -38,7 +39,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]):
strategy_type: The strategy class to use for switching between LLMs.
Defaults to ``ServiceSwitcherStrategyManual``.
"""
super().__init__(llms, strategy_type)
super().__init__(cast(list[FrameProcessor], llms), strategy_type)
@property
def llms(self) -> list[LLMService]:
@@ -47,7 +48,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]):
Returns:
List of LLM services managed by this switcher.
"""
return self.services
return cast(list[LLMService], self.services)
@property
def active_llm(self) -> LLMService:
@@ -56,7 +57,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]):
Returns:
The currently active LLM service, or None if no LLM is active.
"""
return self.strategy.active_service
return cast(LLMService, self.strategy.active_service)
async def run_inference(self, context: LLMContext, **kwargs) -> str | None:
"""Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the given LLM context, using the currently active LLM.

View File

@@ -11,7 +11,7 @@ in sequence and manages frame flow between them, along with helper classes
for pipeline source and sink operations.
"""
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Sequence
from pipecat.frames.frames import Frame
from pipecat.pipeline.base_pipeline import BasePipeline
@@ -98,7 +98,7 @@ class Pipeline(BasePipeline):
def __init__(
self,
processors: list[FrameProcessor],
processors: Sequence[FrameProcessor],
*,
source: FrameProcessor | None = None,
sink: FrameProcessor | None = None,
@@ -106,7 +106,7 @@ class Pipeline(BasePipeline):
"""Initialize the pipeline with a list of processors.
Args:
processors: List of frame processors to connect in sequence.
processors: Sequence of frame processors to connect in sequence.
source: An optional pipeline source processor.
sink: An optional pipeline sink processor.
"""
@@ -116,7 +116,7 @@ class Pipeline(BasePipeline):
# downstream outside of the pipeline.
self._source = source or PipelineSource(self.push_frame, name=f"{self}::Source")
self._sink = sink or PipelineSink(self.push_frame, name=f"{self}::Sink")
self._processors: list[FrameProcessor] = [self._source] + processors + [self._sink]
self._processors: list[FrameProcessor] = [self._source, *processors, self._sink]
self._link_processors()

View File

@@ -742,7 +742,7 @@ class PipelineTask(BasePipelineTask):
await self._observer.cleanup()
# End conversation tracing if it's active - this will also close any active turn span
if self._enable_tracing and hasattr(self, "_turn_trace_observer"):
if self._enable_tracing and self._turn_trace_observer:
self._turn_trace_observer.end_conversation_tracing()
# Cleanup pipeline processors.

View File

@@ -173,6 +173,8 @@ class TaskObserver(BaseObserver):
return proxies
async def _send_to_proxy(self, data: Any):
if not self._proxies:
return
for proxy in self._proxies.values():
await proxy.queue.put(data)

View File

@@ -97,13 +97,20 @@ class SentryMetrics(FrameProcessorMetrics):
Args:
end_time: Optional end timestamp override.
Returns:
MetricsFrame produced by the base class, or None if not measuring.
Returning the frame is required so ``FrameProcessor.stop_ttfb_metrics``
can push it downstream to observers.
"""
await super().stop_ttfb_metrics(end_time=end_time)
frame = await super().stop_ttfb_metrics(end_time=end_time)
if self._sentry_available and self._ttfb_metrics_tx:
await self._sentry_queue.put(self._ttfb_metrics_tx)
self._ttfb_metrics_tx = None
return frame
async def start_processing_metrics(self, *, start_time: float | None = None):
"""Start tracking frame processing metrics.
@@ -126,13 +133,20 @@ class SentryMetrics(FrameProcessorMetrics):
Args:
end_time: Optional end timestamp override.
Returns:
MetricsFrame produced by the base class, or None if not measuring.
Returning the frame is required so ``FrameProcessor.stop_processing_metrics``
can push it downstream to observers.
"""
await super().stop_processing_metrics(end_time=end_time)
frame = await super().stop_processing_metrics(end_time=end_time)
if self._sentry_available and self._processing_metrics_tx:
await self._sentry_queue.put(self._processing_metrics_tx)
self._processing_metrics_tx = None
return frame
async def _sentry_task_handler(self):
"""Background task handler for completing Sentry transactions."""
running = True

View File

@@ -228,7 +228,7 @@ async def configure(
room_properties.enable_dialout = True
# Add SIP configuration if enabled
if sip_enabled:
if sip_enabled and sip_caller_phone:
sip_params = DailyRoomSipParams(
display_name=sip_caller_phone,
video=sip_enable_video,

View File

@@ -156,6 +156,8 @@ def _get_bot_module():
spec = importlib.util.spec_from_file_location(
module_name, os.path.join(cwd, filename)
)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
@@ -386,29 +388,27 @@ def _add_lifespan_to_app(app: FastAPI, new_lifespan):
def _setup_whatsapp_routes(app: FastAPI, args: argparse.Namespace):
"""Set up WhatsApp-specific routes."""
WHATSAPP_APP_SECRET = os.getenv("WHATSAPP_APP_SECRET")
WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
WHATSAPP_TOKEN = os.getenv("WHATSAPP_TOKEN")
WHATSAPP_WEBHOOK_VERIFICATION_TOKEN = os.getenv("WHATSAPP_WEBHOOK_VERIFICATION_TOKEN")
if not all(
[
WHATSAPP_APP_SECRET,
WHATSAPP_PHONE_NUMBER_ID,
WHATSAPP_TOKEN,
WHATSAPP_WEBHOOK_VERIFICATION_TOKEN,
]
):
required_vars = [
"WHATSAPP_APP_SECRET",
"WHATSAPP_PHONE_NUMBER_ID",
"WHATSAPP_TOKEN",
"WHATSAPP_WEBHOOK_VERIFICATION_TOKEN",
]
missing = [v for v in required_vars if not os.getenv(v)]
if missing:
missing_list = "\n ".join(missing)
logger.error(
"""Missing required environment variables for WhatsApp transport:
WHATSAPP_APP_SECRET
WHATSAPP_PHONE_NUMBER_ID
WHATSAPP_TOKEN
WHATSAPP_WEBHOOK_VERIFICATION_TOKEN
f"""Missing required environment variables for WhatsApp transport:
{missing_list}
"""
)
return
WHATSAPP_APP_SECRET = os.environ["WHATSAPP_APP_SECRET"]
WHATSAPP_PHONE_NUMBER_ID = os.environ["WHATSAPP_PHONE_NUMBER_ID"]
WHATSAPP_TOKEN = os.environ["WHATSAPP_TOKEN"]
WHATSAPP_WEBHOOK_VERIFICATION_TOKEN = os.environ["WHATSAPP_WEBHOOK_VERIFICATION_TOKEN"]
try:
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from pipecat.transports.whatsapp.api import WhatsAppWebhookRequest

View File

@@ -122,4 +122,4 @@ class LiveKitRunnerArguments(RunnerArguments):
room_name: str
url: str
token: str | None = None
token: str

View File

@@ -416,9 +416,9 @@ def _get_transport_params(transport_key: str, transport_params: dict[str, Callab
async def _create_telephony_transport(
websocket: WebSocket,
params: Any | None = None,
transport_type: str = None,
call_data: dict = None,
params: Any,
transport_type: str,
call_data: dict,
) -> BaseTransport:
"""Create a telephony transport with pre-parsed WebSocket data.
@@ -433,12 +433,6 @@ async def _create_telephony_transport(
"""
from pipecat.transports.websocket.fastapi import FastAPIWebsocketTransport
if params is None:
raise ValueError(
"FastAPIWebsocketParams must be provided. "
"The serializer and add_wav_header will be set automatically."
)
# Always set add_wav_header to False for telephony
params.add_wav_header = False

View File

@@ -59,7 +59,9 @@ class ExotelFrameSerializer(FrameSerializer):
call_sid: The associated Exotel Call SID (optional, not used in this implementation).
params: Configuration parameters.
"""
super().__init__(params or ExotelFrameSerializer.InputParams())
params = params or ExotelFrameSerializer.InputParams()
super().__init__(params)
self._params: ExotelFrameSerializer.InputParams = params
self._stream_sid = stream_sid
self._call_sid = call_sid

View File

@@ -166,7 +166,9 @@ class GenesysAudioHookSerializer(FrameSerializer):
params: Configuration parameters.
**kwargs: Additional arguments passed to BaseObject (e.g., name).
"""
super().__init__(params or GenesysAudioHookSerializer.InputParams(), **kwargs)
params = params or GenesysAudioHookSerializer.InputParams()
super().__init__(params, **kwargs)
self._params: GenesysAudioHookSerializer.InputParams = params
self._genesys_sample_rate = self._params.genesys_sample_rate
self._sample_rate = 0 # Pipeline input rate, set in setup()

View File

@@ -8,6 +8,7 @@
import base64
import json
from typing import cast
from loguru import logger
@@ -71,7 +72,24 @@ class PlivoFrameSerializer(FrameSerializer):
auth_token: Plivo auth token (required for auto hang-up).
params: Configuration parameters.
"""
super().__init__(params or PlivoFrameSerializer.InputParams())
params = params or PlivoFrameSerializer.InputParams()
super().__init__(params)
self._params: PlivoFrameSerializer.InputParams = params
# Validate hangup-related parameters if auto_hang_up is enabled
if self._params.auto_hang_up:
missing_credentials = []
if not call_id:
missing_credentials.append("call_id")
if not auth_id:
missing_credentials.append("auth_id")
if not auth_token:
missing_credentials.append("auth_token")
if missing_credentials:
raise ValueError(
f"auto_hang_up is enabled but missing required parameters: {', '.join(missing_credentials)}"
)
self._stream_id = stream_id
self._call_id = call_id
@@ -152,23 +170,11 @@ class PlivoFrameSerializer(FrameSerializer):
try:
import aiohttp
auth_id = self._auth_id
auth_token = self._auth_token
call_id = self._call_id
if not call_id or not auth_id or not auth_token:
missing = []
if not call_id:
missing.append("call_id")
if not auth_id:
missing.append("auth_id")
if not auth_token:
missing.append("auth_token")
logger.warning(
f"Cannot hang up Plivo call: missing required parameters: {', '.join(missing)}"
)
return
# __init__ guarantees these are non-None whenever auto_hang_up is True,
# which is the only path that reaches this method.
auth_id = cast(str, self._auth_id)
auth_token = cast(str, self._auth_token)
call_id = cast(str, self._call_id)
# Plivo API endpoint for hanging up calls
endpoint = f"https://api.plivo.com/v1/Account/{auth_id}/Call/{call_id}/"

View File

@@ -83,23 +83,24 @@ class ProtobufFrameSerializer(FrameSerializer):
Serialized frame as bytes, or None if frame type is not serializable.
"""
# Wrapping this messages as a JSONFrame to send
serializable: Frame | MessageFrame = frame
if isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)):
if self.should_ignore_frame(frame):
return None
frame = MessageFrame(
serializable = MessageFrame(
data=json.dumps(frame.message),
)
proto_frame = frame_protos.Frame()
if type(frame) not in self.SERIALIZABLE_TYPES:
logger.warning(f"Frame type {type(frame)} is not serializable")
proto_frame = frame_protos.Frame() # type: ignore[attr-defined]
if type(serializable) not in self.SERIALIZABLE_TYPES:
logger.warning(f"Frame type {type(serializable)} is not serializable")
return None
# ignoring linter errors; we check that type(frame) is in this dict above
proto_optional_name = self.SERIALIZABLE_TYPES[type(frame)] # type: ignore
proto_optional_name = self.SERIALIZABLE_TYPES[type(serializable)] # type: ignore
proto_attr = getattr(proto_frame, proto_optional_name)
for field in dataclasses.fields(frame): # type: ignore
value = getattr(frame, field.name)
for field in dataclasses.fields(serializable): # type: ignore
value = getattr(serializable, field.name)
if value and hasattr(proto_attr, field.name):
setattr(proto_attr, field.name, value)
@@ -114,7 +115,7 @@ class ProtobufFrameSerializer(FrameSerializer):
Returns:
Deserialized frame instance, or None if deserialization fails.
"""
proto = frame_protos.Frame.FromString(data)
proto = frame_protos.Frame.FromString(data) # type: ignore[attr-defined]
which = proto.WhichOneof("frame")
if which not in self.DESERIALIZABLE_FIELDS:
logger.error("Unable to deserialize a valid frame")

View File

@@ -8,10 +8,10 @@
import base64
import json
from typing import cast
import aiohttp
from loguru import logger
from pydantic import BaseModel
from pipecat.audio.dtmf.types import KeypadEntry
from pipecat.audio.utils import (
@@ -46,7 +46,7 @@ class TelnyxFrameSerializer(FrameSerializer):
credentials to be provided.
"""
class InputParams(BaseModel):
class InputParams(FrameSerializer.InputParams):
"""Configuration parameters for TelnyxFrameSerializer.
Parameters:
@@ -82,10 +82,26 @@ class TelnyxFrameSerializer(FrameSerializer):
api_key: Your Telnyx API key (required for auto hang-up).
params: Configuration parameters.
"""
params = params or TelnyxFrameSerializer.InputParams()
super().__init__(params)
self._params: TelnyxFrameSerializer.InputParams = params
# Validate hangup-related parameters if auto_hang_up is enabled
if self._params.auto_hang_up:
missing_credentials = []
if not call_control_id:
missing_credentials.append("call_control_id")
if not api_key:
missing_credentials.append("api_key")
if missing_credentials:
raise ValueError(
f"auto_hang_up is enabled but missing required parameters: {', '.join(missing_credentials)}"
)
self._stream_id = stream_id
self._call_control_id = call_control_id
self._api_key = api_key
self._params = params or TelnyxFrameSerializer.InputParams()
self._params.outbound_encoding = outbound_encoding
self._params.inbound_encoding = inbound_encoding
@@ -163,14 +179,10 @@ class TelnyxFrameSerializer(FrameSerializer):
async def _hang_up_call(self):
"""Hang up the Telnyx call using Telnyx's REST API."""
try:
call_control_id = self._call_control_id
api_key = self._api_key
if not call_control_id or not api_key:
logger.warning(
"Cannot hang up Telnyx call: call_control_id and api_key must be provided"
)
return
# __init__ guarantees these are non-None whenever auto_hang_up is True,
# which is the only path that reaches this method.
call_control_id = cast(str, self._call_control_id)
api_key = cast(str, self._api_key)
# Telnyx API endpoint for hanging up a call
endpoint = f"https://api.telnyx.com/v2/calls/{call_control_id}/actions/hangup"

View File

@@ -8,6 +8,7 @@
import base64
import json
from typing import cast
from loguru import logger
@@ -75,7 +76,9 @@ class TwilioFrameSerializer(FrameSerializer):
edge: Twilio edge location (e.g., "sydney", "dublin"). Must be specified with region.
params: Configuration parameters.
"""
super().__init__(params or TwilioFrameSerializer.InputParams())
params = params or TwilioFrameSerializer.InputParams()
super().__init__(params)
self._params: TwilioFrameSerializer.InputParams = params
# Validate hangup-related parameters if auto_hang_up is enabled
if self._params.auto_hang_up:
@@ -178,9 +181,11 @@ class TwilioFrameSerializer(FrameSerializer):
try:
import aiohttp
account_sid = self._account_sid
auth_token = self._auth_token
call_sid = self._call_sid
# __init__ guarantees these are non-None whenever auto_hang_up is True,
# which is the only path that reaches this method.
account_sid = cast(str, self._account_sid)
auth_token = cast(str, self._auth_token)
call_sid = cast(str, self._call_sid)
region = self._region
edge = self._edge

View File

@@ -54,7 +54,9 @@ class VonageFrameSerializer(FrameSerializer):
Args:
params: Configuration parameters.
"""
super().__init__(params or VonageFrameSerializer.InputParams())
params = params or VonageFrameSerializer.InputParams()
super().__init__(params)
self._params: VonageFrameSerializer.InputParams = params
self._vonage_sample_rate = self._params.vonage_sample_rate
self._sample_rate = 0 # Pipeline input rate

View File

@@ -27,11 +27,59 @@ from pipecat.frames.frames import (
)
from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven
from pipecat.services.stt_service import STTService
from pipecat.transcriptions.language import Language
from pipecat.transcriptions.language import Language, resolve_language
from pipecat.utils.time import time_now_iso8601
from pipecat.utils.tracing.service_decorators import traced_stt
def language_to_deepgram_flux_language(language: Language) -> str | None:
"""Convert a Pipecat Language to a Deepgram Flux language code.
Only honored by the ``flux-general-multi`` model. Locale variants
(e.g. ``Language.EN_GB``) fall back to the base code.
"""
LANGUAGE_MAP = {
Language.DE: "de",
Language.EN: "en",
Language.ES: "es",
Language.FR: "fr",
Language.HI: "hi",
Language.IT: "it",
Language.JA: "ja",
Language.NL: "nl",
Language.PT: "pt",
Language.RU: "ru",
}
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
def _prepare_language_hints(hints: list[Language] | None) -> list[str]:
"""Convert a list of Pipecat Languages to Deepgram Flux codes.
Drops entries that can't be mapped and deduplicates while preserving order.
"""
if not hints:
return []
seen: set[str] = set()
prepared: list[str] = []
for hint in hints:
code = language_to_deepgram_flux_language(hint)
if code is None or code in seen:
continue
seen.add(code)
prepared.append(code)
return prepared
def _code_to_pipecat_language(code: str) -> Language | None:
"""Convert a Deepgram-returned language code to a Pipecat Language."""
try:
return Language(code)
except ValueError:
logger.debug(f"Unmapped Deepgram Flux detected language code: {code}")
return None
class FluxMessageType(StrEnum):
"""Deepgram Flux WebSocket message types.
@@ -73,6 +121,10 @@ class DeepgramFluxSTTSettings(STTSettings):
confidence (default 5000).
keyterm: Keyterms to boost recognition accuracy for specialized terminology.
min_confidence: Minimum confidence required to create a TranscriptionFrame.
language_hints: Languages to bias transcription toward. Only honored by the
``flux-general-multi`` model. An empty list clears any active hints;
``None``/``NOT_GIVEN`` means no hints (auto-detect). Can be updated
mid-stream via ``STTUpdateSettingsFrame``.
"""
eager_eot_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
@@ -80,6 +132,7 @@ class DeepgramFluxSTTSettings(STTSettings):
eot_timeout_ms: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
keyterm: list | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
min_confidence: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
language_hints: list[Language] | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class DeepgramFluxSTTBase(STTService):
@@ -93,7 +146,14 @@ class DeepgramFluxSTTBase(STTService):
Settings = DeepgramFluxSTTSettings
_settings: Settings
_CONFIGURE_FIELDS = {"keyterm", "eot_threshold", "eager_eot_threshold", "eot_timeout_ms"}
_CONFIGURE_FIELDS = {
"keyterm",
"eot_threshold",
"eager_eot_threshold",
"eot_timeout_ms",
"language_hints",
}
_MULTILINGUAL_MODEL = "flux-general-multi"
def __init__(
self,
@@ -200,6 +260,18 @@ class DeepgramFluxSTTBase(STTService):
for tag_value in self._tag:
params.append(urlencode({"tag": tag_value}))
# Add language_hint parameters (only valid on flux-general-multi)
hints = self._settings.language_hints
if hints and not isinstance(hints, _NotGiven):
if self._settings.model == self._MULTILINGUAL_MODEL:
for code in _prepare_language_hints(hints):
params.append(urlencode({"language_hint": code}))
else:
logger.warning(
f"language_hints only supported on {self._MULTILINGUAL_MODEL}; "
f"ignoring hints for model {self._settings.model!r}"
)
return "&".join(params)
async def _send_silence(self, duration_secs: float = 0.5):
@@ -266,6 +338,21 @@ class DeepgramFluxSTTBase(STTService):
if thresholds:
message["thresholds"] = thresholds
if "language_hints" in fields:
if self._settings.model != self._MULTILINGUAL_MODEL:
logger.warning(
f"language_hints only supported on {self._MULTILINGUAL_MODEL}; "
f"skipping Configure update for model {self._settings.model!r}"
)
else:
hints = self._settings.language_hints
# Empty list clears hints; NOT_GIVEN/None also treated as clear
# since we only reach this branch when the user set the field.
if hints is None or isinstance(hints, _NotGiven):
message["language_hints"] = []
else:
message["language_hints"] = _prepare_language_hints(hints)
logger.debug(f"{self}: sending Configure message: {message}")
await self._transport_send_json(message)
@@ -281,8 +368,9 @@ class DeepgramFluxSTTBase(STTService):
"""Apply a settings delta.
Configure-able fields (keyterm, eot_threshold, eager_eot_threshold,
eot_timeout_ms) are sent to Deepgram via a Configure message.
Other fields are stored but cannot be applied to the active connection.
eot_timeout_ms, language_hints) are sent to Deepgram via a Configure
message. Other fields are stored but cannot be applied to the active
connection.
"""
changed = await super()._update_settings(delta)
@@ -520,6 +608,20 @@ class DeepgramFluxSTTBase(STTService):
return None
return sum(confidences) / len(confidences)
def _primary_detected_language(self, data: dict[str, Any]) -> Language | None:
"""Extract the primary detected language from a TurnInfo payload.
On ``flux-general-multi`` the language is read from TurnInfo's
``languages`` field. On ``flux-general-en`` the field is absent, so we
fall back to ``Language.EN`` to match the model's fixed language.
"""
codes = data.get("languages") or []
if codes:
return _code_to_pipecat_language(codes[0])
if self._settings.model == "flux-general-en":
return Language.EN
return None
async def _handle_end_of_turn(self, transcript: str, data: dict[str, Any]):
"""Handle EndOfTurn events from Deepgram Flux.
@@ -543,6 +645,7 @@ class DeepgramFluxSTTBase(STTService):
# Compute the average confidence
average_confidence = self._calculate_average_confidence(data)
detected_language = self._primary_detected_language(data)
if not self._settings.min_confidence or average_confidence > self._settings.min_confidence:
# EndOfTurn means Flux has determined the turn is complete,
@@ -552,7 +655,7 @@ class DeepgramFluxSTTBase(STTService):
transcript,
self._user_id,
time_now_iso8601(),
self._settings.language,
detected_language,
result=data,
finalized=True,
)
@@ -562,7 +665,7 @@ class DeepgramFluxSTTBase(STTService):
f"Transcription confidence below min_confidence threshold: {average_confidence}"
)
await self._handle_transcription(transcript, True, self._settings.language)
await self._handle_transcription(transcript, True, detected_language)
await self.stop_processing_metrics()
await self.broadcast_frame(UserStoppedSpeakingFrame)
await self._call_event_handler("on_end_of_turn", transcript)
@@ -606,7 +709,7 @@ class DeepgramFluxSTTBase(STTService):
transcript,
self._user_id,
time_now_iso8601(),
self._settings.language,
self._primary_detected_language(data),
result=data,
)
)

View File

@@ -23,7 +23,6 @@ from pipecat.services.deepgram.flux.base import (
DeepgramFluxSTTBase,
DeepgramFluxSTTSettings,
)
from pipecat.transcriptions.language import Language
@dataclass
@@ -112,12 +111,13 @@ class DeepgramFluxSageMakerSTTService(DeepgramFluxSTTBase):
# Initialize default settings
default_settings = self.Settings(
model="flux-general-en",
language=Language.EN,
language=None,
eager_eot_threshold=None,
eot_threshold=None,
eot_timeout_ms=None,
keyterm=[],
min_confidence=None,
language_hints=None,
)
# Apply settings delta

View File

@@ -24,7 +24,6 @@ from pipecat.services.deepgram.flux.base import (
FluxMessageType,
)
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language
try:
from websockets.asyncio.client import connect as websocket_connect
@@ -50,6 +49,12 @@ class DeepgramFluxSTTService(DeepgramFluxSTTBase, WebsocketService):
Supports configurable models, VAD events, and various audio processing options
including advanced turn detection and EagerEndOfTurn events for improved conversational AI performance.
For multilingual use, set ``model="flux-general-multi"`` and pass
``language_hints`` to bias detection toward specific languages. Hints can
be updated mid-stream via ``STTUpdateSettingsFrame`` (e.g. to implement a
detect-then-lock flow). ``TranscriptionFrame.language`` reflects whichever
language Flux detected for each turn.
Event handlers available (in addition to base events):
- on_start_of_turn(service, transcript): Deepgram detected start of speech
@@ -156,6 +161,16 @@ class DeepgramFluxSTTService(DeepgramFluxSTTBase, WebsocketService):
tag=["production", "voice-agent"],
),
)
Multilingual usage with language hints::
stt = DeepgramFluxSTTService(
api_key="your-api-key",
settings=DeepgramFluxSTTService.Settings(
model="flux-general-multi",
language_hints=[Language.EN, Language.ES],
),
)
"""
# Note: For DeepgramFluxSTTService, differently from other processes, we need to create
# the _receive_task inside _connect_websocket, because the websocket should only be
@@ -171,12 +186,13 @@ class DeepgramFluxSTTService(DeepgramFluxSTTBase, WebsocketService):
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(
model="flux-general-en",
language=Language.EN,
language=None,
eager_eot_threshold=None,
eot_threshold=None,
eot_timeout_ms=None,
keyterm=[],
min_confidence=None,
language_hints=None,
)
# 2. Apply direct init arg overrides (deprecated)

View File

@@ -621,6 +621,7 @@ class DeepgramSTTService(STTService):
"""
while True:
connect_kwargs = self._build_connect_kwargs()
keepalive_task = None
try:
async with self._client.listen.v1.connect(**connect_kwargs) as connection:
self._connection = connection
@@ -639,7 +640,8 @@ class DeepgramSTTService(STTService):
finally:
self._connection_ready.clear()
self._connection = None
await self.cancel_task(keepalive_task)
if keepalive_task:
await self.cancel_task(keepalive_task)
async def _keepalive_handler(self):
"""Periodically send KeepAlive frames to prevent server-side timeout.

View File

@@ -245,6 +245,35 @@ class ElevenLabsHttpTTSSettings(TTSSettings):
)
def _strip_leading_space(
alignment: Mapping[str, Any], keys: tuple[str, str, str]
) -> Mapping[str, Any]:
"""Return alignment with a prepended space char removed, if present.
Normalized alignment chunks from ElevenLabs begin with a leading space that
marks the prosody/chunk boundary. Left in place, it would prematurely
terminate a partial word carried over from the previous chunk. Stripping it
is lossless for timing: the dropped space's duration is still reflected in
the next char's `charStartTimesMs`, and the chunk's last-element values
(used to advance cumulative time) are untouched.
Args:
alignment: Alignment dict from the API.
keys: Tuple of (chars_key, start_times_key, durations_or_end_times_key)
naming the three parallel arrays — these differ between the
WebSocket and HTTP response schemas.
"""
chars_key, starts_key, tail_key = keys
chars = alignment.get(chars_key) or []
if chars and chars[0] == " ":
return {
chars_key: chars[1:],
starts_key: alignment.get(starts_key, [])[1:],
tail_key: alignment.get(tail_key, [])[1:],
}
return alignment
def calculate_word_times(
alignment_info: Mapping[str, Any],
cumulative_time: float,
@@ -790,8 +819,15 @@ class ElevenLabsTTSService(WebsocketTTSService):
frame = TTSAudioRawFrame(audio, self.sample_rate, 1, context_id=received_ctx_id)
await self.append_to_audio_context(received_ctx_id, frame)
if msg.get("alignment"):
alignment = msg["alignment"]
if msg.get("normalizedAlignment"):
# Use normalizedAlignment (what was actually spoken) rather than
# alignment (the input text), so word timestamps stay accurate
# when a pronunciation dictionary or text normalization rewrites
# the input.
alignment = _strip_leading_space(
msg["normalizedAlignment"],
("chars", "charStartTimesMs", "charDurationsMs"),
)
word_times, self._partial_word, self._partial_word_start_time = (
calculate_word_times(
alignment,
@@ -1296,21 +1332,30 @@ class ElevenLabsHttpTTSService(TTSService):
audio, self.sample_rate, 1, context_id=context_id
)
# Process alignment if present
if data and "alignment" in data:
alignment = data["alignment"]
if alignment: # Ensure alignment is not None
# Get end time of the last character in this chunk
char_end_times = alignment.get("character_end_times_seconds", [])
if char_end_times:
chunk_end_time = char_end_times[-1]
# Update to the longest end time seen so far
utterance_duration = max(utterance_duration, chunk_end_time)
# Process alignment if present. Use normalized_alignment
# (what was actually spoken) so word timestamps stay
# accurate when a pronunciation dictionary or text
# normalization rewrites the input.
if data and data.get("normalized_alignment"):
alignment = _strip_leading_space(
data["normalized_alignment"],
(
"characters",
"character_start_times_seconds",
"character_end_times_seconds",
),
)
# Get end time of the last character in this chunk
char_end_times = alignment.get("character_end_times_seconds", [])
if char_end_times:
chunk_end_time = char_end_times[-1]
# Update to the longest end time seen so far
utterance_duration = max(utterance_duration, chunk_end_time)
# Calculate word timestamps
word_times = self.calculate_word_times(alignment)
if word_times:
await self.add_word_timestamps(word_times, context_id)
# Calculate word timestamps
word_times = self.calculate_word_times(alignment)
if word_times:
await self.add_word_timestamps(word_times, context_id)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse JSON from stream: {e}")
continue

View File

@@ -2,21 +2,44 @@
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES.
#
"""NVIDIA NIM API service implementation.
This module provides a service for interacting with NVIDIA's NIM (NVIDIA Inference
Microservice) API while maintaining compatibility with the OpenAI-style interface.
Refer to the NVIDIA NIM LLM API documentation for available models and usage:
https://docs.api.nvidia.com/nim/reference/llm-apis
"""
from collections.abc import AsyncIterator
from dataclasses import dataclass
from enum import StrEnum
from loguru import logger
from openai.types.chat import ChatCompletionChunk
from pipecat.frames.frames import (
LLMThoughtEndFrame,
LLMThoughtStartFrame,
LLMThoughtTextFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.openai.base_llm import BaseOpenAILLMService
from pipecat.services.openai.llm import OpenAILLMService
_THINK_OPEN = "<think>"
_THINK_CLOSE = "</think>"
class _ThinkTagState(StrEnum):
DETECTING = "detecting"
IN_THOUGHT = "in_thought"
CONTENT = "content"
@dataclass
class NvidiaLLMSettings(BaseOpenAILLMService.Settings):
@@ -28,9 +51,19 @@ class NvidiaLLMSettings(BaseOpenAILLMService.Settings):
class NvidiaLLMService(OpenAILLMService):
"""A service for interacting with NVIDIA's NIM (NVIDIA Inference Microservice) API.
This service extends OpenAILLMService to work with NVIDIA's NIM API while maintaining
compatibility with the OpenAI-style interface. It specifically handles the difference
in token usage reporting between NIM (incremental) and OpenAI (final summary).
This service extends OpenAILLMService to work with NVIDIA's NIM API while
maintaining compatibility with the OpenAI-style interface. It handles:
- Incremental token usage reporting (NIM sends per-chunk counts instead
of a final summary)
- Detection and filtering of leading ``<think>``/``</think>`` content for
models that emit reasoning inline before visible output (e.g.
DeepSeek-R1, some nemotron models)
- Extraction of ``reasoning_content`` from the streaming delta for models
with API-level reasoning separation (e.g. Nemotron Nano models)
Reasoning content is emitted as ``LLMThought*Frame`` objects, keeping it
accessible to observers and logging without sending it to TTS.
"""
Settings = NvidiaLLMSettings
@@ -39,7 +72,7 @@ class NvidiaLLMService(OpenAILLMService):
def __init__(
self,
*,
api_key: str,
api_key: str | None = None,
base_url: str = "https://integrate.api.nvidia.com/v1",
model: str | None = None,
settings: Settings | None = None,
@@ -48,10 +81,12 @@ class NvidiaLLMService(OpenAILLMService):
"""Initialize the NvidiaLLMService.
Args:
api_key: The API key for accessing NVIDIA's NIM API.
base_url: The base URL for NIM API. Defaults to "https://integrate.api.nvidia.com/v1".
api_key: NVIDIA API key for authentication. Required when using the
cloud endpoint. Not needed for local NIM deployments.
base_url: The base URL for NIM API. Defaults to NVIDIA's cloud endpoint.
For local deployments, pass the local address (e.g. ``http://localhost:8000/v1``).
model: The model identifier to use. Defaults to
"nvidia/llama-3.1-nemotron-70b-instruct".
"nvidia/nemotron-3-nano-30b-a3b".
.. deprecated:: 0.0.105
Use ``settings=NvidiaLLMService.Settings(model=...)`` instead.
@@ -61,7 +96,7 @@ class NvidiaLLMService(OpenAILLMService):
**kwargs: Additional keyword arguments passed to OpenAILLMService.
"""
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(model="nvidia/llama-3.1-nemotron-70b-instruct")
default_settings = self.Settings(model="nvidia/nemotron-3-nano-30b-a3b")
# 2. Apply direct init arg overrides (deprecated)
if model is not None:
@@ -75,6 +110,14 @@ class NvidiaLLMService(OpenAILLMService):
default_settings.apply_update(settings)
super().__init__(api_key=api_key, base_url=base_url, settings=default_settings, **kwargs)
if "api.nvidia.com" in base_url and not api_key:
logger.warning(
"NvidiaLLMService: Using the cloud endpoint but no API key was provided. "
"An API key is required for the cloud endpoint. "
"Set base_url to your local NIM endpoint for local deployments."
)
# Counters for accumulating token usage metrics
self._prompt_tokens = 0
self._completion_tokens = 0
@@ -82,24 +125,202 @@ class NvidiaLLMService(OpenAILLMService):
self._has_reported_prompt_tokens = False
self._is_processing = False
async def _process_context(self, context: LLMContext):
"""Process a context through the LLM and accumulate token usage metrics.
def _reset_response_state(self):
"""Reset per-response state at the start of each LLM call.
This method overrides the parent class implementation to handle NVIDIA's
incremental token reporting style, accumulating the counts and reporting
them once at the end of processing.
Args:
context: The context to process, containing messages and other information
needed for the LLM interaction.
Resets token accumulation counters, leading-think-tag detection state,
and reasoning-content field tracking.
"""
# Reset all counters and flags at the start of processing
self._prompt_tokens = 0
self._completion_tokens = 0
self._total_tokens = 0
self._has_reported_prompt_tokens = False
self._is_processing = True
self._think_tag_state = _ThinkTagState.DETECTING
self._think_tag_buffer = ""
# reasoning_content field tracking
self._has_reasoning_field = False
async def _filter_thinking_content(self, text: str) -> str | None:
"""Filter leading ``<think>`` tags from content and emit thought frames.
Uses a three-state machine optimized for the common provider pattern
where a response either begins with a ``<think>`` block or contains no
think tags at all. It returns only visible content to the base OpenAI
processing loop while emitting hidden reasoning as ``LLMThought*Frame``
side effects.
- ``detecting``: Buffers the start of the stream to check for
``<think>``.
- ``in_thought``: Inside a leading think block; emits
``LLMThoughtTextFrame`` until ``</think>`` is found.
- ``content``: Normal content; passthrough.
Non-reasoning models transition from ``detecting`` to ``content``
on the first chunk with zero buffering overhead after that.
Args:
text: The text content from the LLM to filter.
Returns:
The non-reasoning content that should continue through the base
OpenAI content path, or ``None`` if this chunk should not emit
normal content.
"""
if self._think_tag_state == _ThinkTagState.CONTENT:
return text
self._think_tag_buffer += text
if self._think_tag_state == _ThinkTagState.DETECTING:
if len(self._think_tag_buffer) < len(_THINK_OPEN):
if _THINK_OPEN.startswith(self._think_tag_buffer):
return None
self._think_tag_state = _ThinkTagState.CONTENT
passthrough = self._think_tag_buffer
self._think_tag_buffer = ""
return passthrough
if self._think_tag_buffer.startswith(_THINK_OPEN):
self._think_tag_state = _ThinkTagState.IN_THOUGHT
await self.push_frame(LLMThoughtStartFrame())
self._think_tag_buffer = self._think_tag_buffer[len(_THINK_OPEN) :]
else:
self._think_tag_state = _ThinkTagState.CONTENT
passthrough = self._think_tag_buffer
self._think_tag_buffer = ""
return passthrough
if self._think_tag_state == _ThinkTagState.IN_THOUGHT:
idx = self._think_tag_buffer.find(_THINK_CLOSE)
if idx != -1:
thought = self._think_tag_buffer[:idx]
if thought:
await self.push_frame(LLMThoughtTextFrame(text=thought))
await self.push_frame(LLMThoughtEndFrame())
remainder = self._think_tag_buffer[idx + len(_THINK_CLOSE) :]
self._think_tag_buffer = ""
self._think_tag_state = _ThinkTagState.CONTENT
return remainder or None
else:
safe_end = len(self._think_tag_buffer) - len(_THINK_CLOSE) + 1
if safe_end > 0:
await self.push_frame(
LLMThoughtTextFrame(text=self._think_tag_buffer[:safe_end])
)
self._think_tag_buffer = self._think_tag_buffer[safe_end:]
return None
async def _flush_reasoning_state(self):
"""Flush buffered reasoning state at normal stream completion.
Emits any buffered trailing thought text, closes open thought frames,
and forwards any buffered pre-content text that was held while deciding
whether the stream began with ``<think>``.
"""
if self._think_tag_state == _ThinkTagState.IN_THOUGHT:
if self._think_tag_buffer:
await self.push_frame(LLMThoughtTextFrame(text=self._think_tag_buffer))
await self.push_frame(LLMThoughtEndFrame())
elif self._think_tag_state == _ThinkTagState.DETECTING and self._think_tag_buffer:
await super()._push_llm_text(self._think_tag_buffer)
self._think_tag_buffer = ""
self._think_tag_state = _ThinkTagState.CONTENT
if self._has_reasoning_field:
await self.push_frame(LLMThoughtEndFrame())
self._has_reasoning_field = False
async def get_chat_completions(self, context: LLMContext) -> AsyncIterator[ChatCompletionChunk]:
"""Wrap the chat completion stream to handle ``reasoning_content``.
Models with API-level reasoning separation (e.g. Nemotron Nano)
include a ``reasoning_content`` field on the streaming delta. This
wrapper extracts those chunks and emits them as ``LLMThought*Frame``
objects. It also rewrites streamed ``delta.content`` so leading
``<think>`` sections are removed before the base OpenAI loop processes
visible content.
Args:
context: The LLM context for the completion request.
Returns:
An async iterator of chat completion chunks where
``reasoning_content`` has been emitted as ``LLMThought*Frame``
side effects.
"""
stream = await super().get_chat_completions(context)
return self._handle_reasoning_content(stream)
async def _handle_reasoning_content(
self, stream: AsyncIterator[ChatCompletionChunk]
) -> AsyncIterator[ChatCompletionChunk]:
"""Handle ``reasoning_content`` and leading ``<think>`` tags in a chunk stream.
Inspects each chunk for a ``reasoning_content`` field on the delta and
emits ``LLMThoughtStartFrame`` / ``LLMThoughtTextFrame`` /
``LLMThoughtEndFrame`` as side effects. It also strips ``<think>``
blocks from ``delta.content`` before yielding the chunk so the base
OpenAI loop only sees user-facing content. Every chunk is still yielded
so the base streaming loop can process metadata such as token usage,
model name, tool calls, and audio transcripts.
Notes:
Stream cleanup is owned by the base OpenAI processing loop
(``BaseOpenAILLMService._process_context``), which wraps the stream
in its own closing context manager.
Args:
stream: The original chat completion stream.
Yields:
Chat completion chunks with any leading ``<think>`` content removed
from ``delta.content`` before they reach the base OpenAI loop.
"""
async for chunk in stream:
if chunk.choices and len(chunk.choices) > 0 and chunk.choices[0].delta:
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None)
if rc:
if not self._has_reasoning_field:
self._has_reasoning_field = True
await self.push_frame(LLMThoughtStartFrame())
await self.push_frame(LLMThoughtTextFrame(text=rc))
elif self._has_reasoning_field and delta.content:
await self.push_frame(LLMThoughtEndFrame())
self._has_reasoning_field = False
if delta.content:
delta.content = await self._filter_thinking_content(delta.content)
yield chunk
await self._flush_reasoning_state()
async def _process_context(self, context: LLMContext):
"""Process a context through the LLM and accumulate token usage metrics.
Delegates to the base OpenAI streaming loop while adding
NVIDIA-specific behavior:
- ``reasoning_content`` and leading ``<think>`` content are
intercepted via the ``get_chat_completions`` stream wrapper and
emitted as
``LLMThought*Frame`` objects.
- Incremental token counts are accumulated and reported as final
totals.
Args:
context: The context to process, containing messages and other
information needed for the LLM interaction.
"""
self._reset_response_state()
# Wrap in try/finally to guarantee accumulated token metrics are
# reported and _is_processing is cleared even on cancellation.
try:
await super()._process_context(context)
finally:

View File

@@ -91,6 +91,7 @@ class ModelConfig:
supports_prompt: Whether the model accepts prompt parameter.
supports_mode: Whether the model accepts mode parameter.
supports_language: Whether the model accepts language parameter.
supports_vad_params: Whether the model accepts fine-grained VAD parameters.
default_language: Default language code (None = auto-detect).
default_mode: Default mode (None = not applicable).
use_translate_endpoint: Whether to use speech_to_text_translate_streaming endpoint.
@@ -100,6 +101,7 @@ class ModelConfig:
supports_prompt: bool
supports_mode: bool
supports_language: bool
supports_vad_params: bool
default_language: str | None
default_mode: str | None
use_translate_endpoint: bool
@@ -111,6 +113,7 @@ MODEL_CONFIGS: dict[str, ModelConfig] = {
supports_prompt=False,
supports_mode=False,
supports_language=True,
supports_vad_params=False,
default_language="unknown",
default_mode=None,
use_translate_endpoint=False,
@@ -120,6 +123,7 @@ MODEL_CONFIGS: dict[str, ModelConfig] = {
supports_prompt=True,
supports_mode=False,
supports_language=False,
supports_vad_params=False,
default_language=None, # Auto-detects language
default_mode=None,
use_translate_endpoint=True,
@@ -129,6 +133,7 @@ MODEL_CONFIGS: dict[str, ModelConfig] = {
supports_prompt=False,
supports_mode=True,
supports_language=True,
supports_vad_params=True,
default_language="unknown",
default_mode="transcribe",
use_translate_endpoint=False,
@@ -146,11 +151,43 @@ class SarvamSTTSettings(STTSettings):
Only applicable to models that support prompts (e.g., saaras:v2.5).
vad_signals: Enable VAD signals in response.
high_vad_sensitivity: Enable high VAD sensitivity.
positive_speech_threshold: VAD probability threshold (0.0-1.0) above which
a frame is considered speech. Only for saaras:v3.
negative_speech_threshold: VAD probability threshold (0.0-1.0) below which
a frame is considered silence. Only for saaras:v3.
min_speech_frames: Minimum consecutive speech frames to start a speech
segment. Only for saaras:v3.
first_turn_min_speech_frames: Minimum speech frames for the first user
turn. Only for saaras:v3.
negative_frames_count: Number of silence frames within the window to end
a speech segment. Only for saaras:v3.
negative_frames_window: Sliding window size (in frames) for counting
negative frames. Only for saaras:v3.
start_speech_volume_threshold: Volume level (dB) below which audio is
too quiet to be speech. Only for saaras:v3.
interrupt_min_speech_frames: Minimum speech frames to register a
barge-in/interruption. Only for saaras:v3.
pre_speech_pad_frames: Number of audio frames to prepend before detected
speech onset. Only for saaras:v3.
num_initial_ignored_frames: Number of leading audio frames to skip at
connection start. Only for saaras:v3.
"""
prompt: str | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
vad_signals: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
high_vad_sensitivity: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
positive_speech_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
negative_speech_threshold: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
min_speech_frames: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
first_turn_min_speech_frames: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
negative_frames_count: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
negative_frames_window: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
start_speech_volume_threshold: float | None | _NotGiven = field(
default_factory=lambda: NOT_GIVEN
)
interrupt_min_speech_frames: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
pre_speech_pad_frames: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
num_initial_ignored_frames: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class SarvamSTTService(STTService):
@@ -190,7 +227,7 @@ class SarvamSTTService(STTService):
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.
high_vad_sensitivity: Enable high VAD (Voice Activity Detection) sensitivity. Defaults to None.
high_vad_sensitivity: Enable high VAD sensitivity. Defaults to None.
"""
language: Language | None = None
@@ -244,11 +281,21 @@ class SarvamSTTService(STTService):
"""
# --- 1. Hardcoded defaults ---
default_settings = self.Settings(
model="saarika:v2.5",
model="saaras:v3",
language=None,
prompt=None,
vad_signals=None,
high_vad_sensitivity=None,
positive_speech_threshold=None,
negative_speech_threshold=None,
min_speech_frames=None,
first_turn_min_speech_frames=None,
negative_frames_count=None,
negative_frames_window=None,
start_speech_volume_threshold=None,
interrupt_min_speech_frames=None,
pre_speech_pad_frames=None,
num_initial_ignored_frames=None,
)
# --- 2. Deprecated direct-arg overrides ---
@@ -289,6 +336,26 @@ class SarvamSTTService(STTService):
f"Model '{resolved_model}' does not support language parameter (auto-detects language)."
)
if not self._config.supports_vad_params:
vad_param_names = [
"positive_speech_threshold",
"negative_speech_threshold",
"min_speech_frames",
"first_turn_min_speech_frames",
"negative_frames_count",
"negative_frames_window",
"start_speech_volume_threshold",
"interrupt_min_speech_frames",
"pre_speech_pad_frames",
"num_initial_ignored_frames",
]
for param_name in vad_param_names:
if getattr(default_settings, param_name) is not None:
raise ValueError(
f"Model '{resolved_model}' does not support {param_name} parameter. "
f"Fine-grained VAD parameters are only supported by saaras:v3."
)
# Resolve mode default from model config
if mode is None:
mode = self._config.default_mode
@@ -393,10 +460,44 @@ class SarvamSTTService(STTService):
f"Model '{self._settings.model}' does not support prompt parameter."
)
if isinstance(delta, self.Settings) and not self._config.supports_vad_params:
vad_param_names = [
"positive_speech_threshold",
"negative_speech_threshold",
"min_speech_frames",
"first_turn_min_speech_frames",
"negative_frames_count",
"negative_frames_window",
"start_speech_volume_threshold",
"interrupt_min_speech_frames",
"pre_speech_pad_frames",
"num_initial_ignored_frames",
]
for param_name in vad_param_names:
val = getattr(delta, param_name, NOT_GIVEN)
if is_given(val) and val is not None:
raise ValueError(
f"Model '{self._settings.model}' does not support {param_name} "
f"parameter. Fine-grained VAD parameters are only supported by saaras:v3."
)
changed = await super()._update_settings(delta)
# Language and prompt are WebSocket connect-time parameters; reconnect to apply.
reconnect_fields = {"language", "prompt"}
# These are all WebSocket connect-time parameters; reconnect to apply.
reconnect_fields = {
"language",
"prompt",
"positive_speech_threshold",
"negative_speech_threshold",
"min_speech_frames",
"first_turn_min_speech_frames",
"negative_frames_count",
"negative_frames_window",
"start_speech_volume_threshold",
"interrupt_min_speech_frames",
"pre_speech_pad_frames",
"num_initial_ignored_frames",
}
if changed.keys() & reconnect_fields:
await self._disconnect()
await self._connect()
@@ -535,6 +636,24 @@ class SarvamSTTService(STTService):
"true" if self._settings.high_vad_sensitivity else "false"
)
# Fine-grained VAD parameters (saaras:v3 only, sent as strings per SDK spec)
if self._config.supports_vad_params:
_vad_params = {
"positive_speech_threshold": self._settings.positive_speech_threshold,
"negative_speech_threshold": self._settings.negative_speech_threshold,
"min_speech_frames": self._settings.min_speech_frames,
"first_turn_min_speech_frames": self._settings.first_turn_min_speech_frames,
"negative_frames_count": self._settings.negative_frames_count,
"negative_frames_window": self._settings.negative_frames_window,
"start_speech_volume_threshold": self._settings.start_speech_volume_threshold,
"interrupt_min_speech_frames": self._settings.interrupt_min_speech_frames,
"pre_speech_pad_frames": self._settings.pre_speech_pad_frames,
"num_initial_ignored_frames": self._settings.num_initial_ignored_frames,
}
for k, v in _vad_params.items():
if v is not None:
connect_kwargs[k] = str(v)
# Add language_code for models that support it
language_string = self._get_language_string()
if language_string is not None:

View File

@@ -125,7 +125,7 @@ class SmallestTTSService(InterruptibleTTSService):
self,
*,
api_key: str,
base_url: str = "wss://waves-api.smallest.ai",
base_url: str = "wss://api.smallest.ai",
sample_rate: int | None = None,
settings: Settings | None = None,
**kwargs,
@@ -174,6 +174,10 @@ class SmallestTTSService(InterruptibleTTSService):
"""
return True
async def flush_audio(self, context_id: str | None = None):
"""Flush any pending audio data."""
logger.trace(f"{self}: flushing audio")
def language_to_service_language(self, language: Language) -> str | None:
"""Convert a Language enum to Smallest service language format.
@@ -217,7 +221,7 @@ class SmallestTTSService(InterruptibleTTSService):
def _build_websocket_url(self) -> str:
"""Build the WebSocket URL from base URL and model."""
return f"{self._base_url}/api/v1/{self._settings.model}/get_speech/stream"
return f"{self._base_url}/waves/v1/{self._settings.model}/get_speech/stream"
async def start(self, frame: StartFrame):
"""Start the Smallest TTS service.
@@ -350,17 +354,15 @@ class SmallestTTSService(InterruptibleTTSService):
await self._send_keepalive()
async def _send_keepalive(self):
"""Send a flush message to keep the connection alive."""
"""Send a silent message to keep the WebSocket connection alive."""
if self._websocket and self._websocket.state is State.OPEN:
msg = {"flush": True}
msg = {
"text": " ",
"voice_id": self._settings.voice,
"language": self._settings.language,
}
await self._websocket.send(json.dumps(msg))
async def flush_audio(self, context_id: str | None = None):
"""Flush any pending audio synthesis."""
if not self._websocket or self._websocket.state is State.CLOSED:
return
await self._get_websocket().send(json.dumps({"flush": True}))
async def _receive_messages(self):
"""Receive and process messages from the Smallest WebSocket API."""
async for message in self._get_websocket():

View File

@@ -57,3 +57,4 @@ WHISPER_TTFS_P99: float = DEFAULT_TTFS_P99
# No benchmark available yet; using conservative default
MISTRAL_TTFS_P99: float = DEFAULT_TTFS_P99
SMALLEST_TTFS_P99: float = DEFAULT_TTFS_P99
XAI_TTFS_P99: float = DEFAULT_TTFS_P99

View File

@@ -293,6 +293,7 @@ class TTSService(AIService):
self._processing_text: bool = False
self._tts_contexts: dict[str, TTSContext] = {}
self._streamed_text: str = ""
self._sent_non_whitespace_in_context: bool = False
self._text_aggregation_metrics_started: bool = False
# Word timestamp state
@@ -684,6 +685,7 @@ class TTSService(AIService):
# Reset aggregator state
self._processing_text = False
self._sent_non_whitespace_in_context = False
if isinstance(frame, LLMFullResponseEndFrame):
if self._push_text_frames:
# Route through the serialization queue so the frame is
@@ -698,6 +700,8 @@ class TTSService(AIService):
elif isinstance(frame, TTSSpeakFrame):
# Store if we were processing text or not so we can set it back.
processing_text = self._processing_text
saved_sent_non_whitespace = self._sent_non_whitespace_in_context
self._sent_non_whitespace_in_context = False
# TTSSpeakFrame is independent — temporarily clear the turn context
# so create_context_id() generates a fresh UUID for this utterance.
saved_turn_context_id = self._turn_context_id
@@ -718,6 +722,7 @@ class TTSService(AIService):
# the TTS. We pause to avoid audio overlapping.
await self._maybe_pause_frame_processing()
self._turn_context_id = saved_turn_context_id
self._sent_non_whitespace_in_context = saved_sent_non_whitespace
self._processing_text = processing_text
elif isinstance(frame, TTSUpdateSettingsFrame):
if frame.service is not None and frame.service is not self:
@@ -844,6 +849,7 @@ class TTSService(AIService):
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
self._processing_text = False
self._sent_non_whitespace_in_context = False
await self._text_aggregator.handle_interruption()
for filter in self._text_filters:
await filter.handle_interruption()
@@ -901,13 +907,22 @@ class TTSService(AIService):
await self.push_frame(src_frame)
return
# Remove leading newlines only
text = text.lstrip("\n")
# Don't send only whitespace. This causes problems for some TTS models. But also don't
# strip all whitespace, as whitespace can influence prosody.
if not text.strip():
return
# Whitespace gating depends on aggregation mode:
# - Token streaming: drop all leading whitespace at the start of a context, as
# nothing substantive has been sent yet for it to attach to. Once a non-whitespace
# token has been sent, send whitespace as-is since it can influence prosody between
# non-whitespace tokens.
#
# - Sentence aggregation: strip leading newlines only and drop pure-whitespace frames.
if self._is_streaming_tokens:
if not self._sent_non_whitespace_in_context:
text = text.lstrip()
if not text:
return
else:
text = text.lstrip("\n")
if not text.strip():
return
# This is just a flag that indicates if we sent something to the TTS
# service. It will be cleared if we sent text because of a TTSSpeakFrame
@@ -929,9 +944,15 @@ class TTSService(AIService):
await filter.reset_interruption()
text = await filter.filter(text)
if not text.strip():
if not self._is_streaming_tokens:
await self.stop_processing_metrics()
# Post-filter whitespace gate. Mirrors the pre-filter logic so filter
# output that collapses to whitespace-only is handled consistently.
if self._is_streaming_tokens:
# If empty, or only-whitespace and we haven't sent any non-whitespace, skip.
if not text or (not text.strip() and not self._sent_non_whitespace_in_context):
return
self._sent_non_whitespace_in_context = True
elif not text.strip():
await self.stop_processing_metrics()
return
# Create context ID and store metadata

View File

@@ -0,0 +1,411 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""xAI speech-to-text service implementation.
This module provides integration with xAI's real-time speech-to-text WebSocket
API documented at https://docs.x.ai/developers/rest-api-reference/inference/voice.
"""
import asyncio
import json
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode
from loguru import logger
from pipecat import version as pipecat_version
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InterimTranscriptionFrame,
StartFrame,
TranscriptionFrame,
)
from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven
from pipecat.services.stt_latency import XAI_TTFS_P99
from pipecat.services.stt_service import WebsocketSTTService
from pipecat.transcriptions.language import Language, resolve_language
from pipecat.utils.time import time_now_iso8601
from pipecat.utils.tracing.service_decorators import traced_stt
try:
from websockets.asyncio.client import connect as websocket_connect
from websockets.protocol import State
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error('In order to use xAI STT, you need to `pip install "pipecat-ai[xai]"`.')
raise Exception(f"Missing module: {e}")
def language_to_xai_stt_language(language: Language) -> str | None:
"""Convert a Language enum to the xAI STT language code.
xAI STT accepts two-letter language codes (e.g. ``en``, ``fr``, ``de``,
``ja``). When set, the server applies Inverse Text Normalization.
Args:
language: The Language enum value to convert.
Returns:
The corresponding xAI STT language code, or None if not supported.
"""
LANGUAGE_MAP = {
Language.AR: "ar",
Language.BN: "bn",
Language.DE: "de",
Language.EN: "en",
Language.ES: "es",
Language.FR: "fr",
Language.HI: "hi",
Language.ID: "id",
Language.IT: "it",
Language.JA: "ja",
Language.KO: "ko",
Language.PT: "pt",
Language.RU: "ru",
Language.TR: "tr",
Language.VI: "vi",
Language.ZH: "zh",
}
return resolve_language(language, LANGUAGE_MAP, use_base_code=True)
@dataclass
class XAISTTSettings(STTSettings):
"""Settings for XAISTTService.
Parameters:
interim_results: When True, partial transcripts are emitted
approximately every 500ms.
endpointing: Silence duration in milliseconds that triggers a
speech-final event. Range 0-5000. Server default is 10ms.
multichannel: When True, transcribes each interleaved channel
independently. Requires ``channels`` >= 2.
channels: Number of interleaved channels (2-8). Required when
``multichannel`` is True.
diarize: When True, the server attaches a ``speaker`` field to each
word identifying the detected speaker.
"""
interim_results: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
endpointing: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
multichannel: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
channels: int | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
diarize: bool | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
class XAISTTService(WebsocketSTTService):
"""xAI real-time speech-to-text service.
Streams audio to xAI's WebSocket STT endpoint and emits interim and final
transcription frames. The ``XAI_API_KEY`` is passed directly as a Bearer
token on the WebSocket handshake.
The connection is persistent: audio is streamed continuously and the
server emits ``transcript.partial`` events with ``is_final`` and
``speech_final`` flags to mark utterance boundaries. If the connection
drops mid-session, the base class reconnects automatically.
"""
Settings = XAISTTSettings
_settings: Settings
def __init__(
self,
*,
api_key: str,
ws_url: str = "wss://api.x.ai/v1/stt",
sample_rate: int = 16000,
encoding: str = "pcm",
settings: Settings | None = None,
ttfs_p99_latency: float | None = XAI_TTFS_P99,
**kwargs,
):
"""Initialize the xAI STT service.
Args:
api_key: xAI API key (used as Bearer for the WebSocket handshake).
ws_url: WebSocket endpoint URL. Defaults to ``wss://api.x.ai/v1/stt``.
sample_rate: Audio sample rate in Hz. Supported values: 8000,
16000, 22050, 24000, 44100, 48000. Defaults to 16000.
encoding: Audio encoding. One of ``"pcm"`` (signed 16-bit LE),
``"mulaw"``, or ``"alaw"``. Defaults to ``"pcm"``.
settings: Runtime-updatable settings overriding defaults.
ttfs_p99_latency: P99 latency from speech end to final transcript
in seconds. See https://github.com/pipecat-ai/stt-benchmark.
**kwargs: Additional arguments passed to WebsocketSTTService.
"""
default_settings = self.Settings(
model=None,
language=Language.EN,
interim_results=True,
endpointing=None,
multichannel=None,
channels=None,
diarize=None,
)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
sample_rate=sample_rate,
settings=default_settings,
ttfs_p99_latency=ttfs_p99_latency,
**kwargs,
)
self._api_key = api_key
self._ws_url = ws_url
self._encoding = encoding
self._receive_task: asyncio.Task | None = None
self._session_ready = asyncio.Event()
def can_generate_metrics(self) -> bool:
"""Check if the service can generate metrics.
Returns:
True if metrics generation is supported.
"""
return True
def language_to_service_language(self, language: Language) -> str | None:
"""Convert a Language enum to the xAI STT language code."""
return language_to_xai_stt_language(language)
async def _update_settings(self, delta: Settings) -> dict[str, Any]:
"""Apply a settings delta and reconnect to apply changes.
xAI STT configures the session via WebSocket query parameters, so any
change requires a fresh connection.
"""
changed = await super()._update_settings(delta)
if not changed:
return changed
await self._disconnect()
await self._connect()
return changed
async def start(self, frame: StartFrame):
"""Start the speech-to-text service."""
await super().start(frame)
await self._connect()
async def stop(self, frame: EndFrame):
"""Stop the speech-to-text service."""
await super().stop(frame)
await self._disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the speech-to-text service."""
await super().cancel(frame)
await self._disconnect()
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
"""Forward raw audio bytes to the xAI STT WebSocket.
Transcription frames are pushed from the receive task, not yielded
from this coroutine.
"""
if self._websocket and self._websocket.state is State.OPEN and self._session_ready.is_set():
try:
await self._websocket.send(audio)
except Exception as e:
await self.push_error(error_msg=f"xAI STT send failed: {e}", exception=e)
yield None
def _build_ws_url(self) -> str:
"""Build the WebSocket URL with session query parameters."""
s = self._settings
params: dict[str, Any] = {
"sample_rate": self.sample_rate,
"encoding": self._encoding,
}
if s.language is not None:
params["language"] = s.language
optional_fields = {
"interim_results": s.interim_results,
"endpointing": s.endpointing,
"multichannel": s.multichannel,
"channels": s.channels,
"diarize": s.diarize,
}
for key, val in optional_fields.items():
if val is None:
continue
if isinstance(val, bool):
params[key] = str(val).lower()
else:
params[key] = val
return f"{self._ws_url}?{urlencode(params)}"
async def _connect(self):
"""Establish the WebSocket connection and start the receive task."""
await super()._connect()
await self._connect_websocket()
if self._websocket and not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
async def _disconnect(self):
"""Tear down the WebSocket connection and cancel the receive task."""
await super()._disconnect()
try:
if self._websocket and self._websocket.state is State.OPEN:
await self._websocket.send(json.dumps({"type": "audio.done"}))
except Exception as e:
logger.debug(f"{self} error sending audio.done during disconnect: {e}")
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self):
"""Open a WebSocket connection to the xAI STT endpoint."""
try:
if self._websocket and self._websocket.state is State.OPEN:
return
logger.debug("Connecting to xAI STT WebSocket")
self._session_ready.clear()
ws_url = self._build_ws_url()
headers = {
"Authorization": f"Bearer {self._api_key}",
"User-Agent": f"xAI/1.0 (integration=Pipecat/{pipecat_version()})",
}
self._websocket = await websocket_connect(ws_url, additional_headers=headers)
await self._call_event_handler("on_connected")
logger.debug(f"{self} connected to xAI STT WebSocket")
except Exception as e:
await self.push_error(error_msg=f"Unable to connect to xAI STT: {e}", exception=e)
raise
async def _disconnect_websocket(self):
"""Close the WebSocket connection."""
try:
if self._websocket:
logger.debug("Disconnecting from xAI STT WebSocket")
await self._websocket.close()
except Exception as e:
await self.push_error(error_msg=f"Error closing xAI STT websocket: {e}", exception=e)
finally:
self._websocket = None
self._session_ready.clear()
await self._call_event_handler("on_disconnected")
async def _receive_messages(self):
"""Receive and dispatch xAI STT WebSocket messages."""
if not self._websocket:
raise Exception("Websocket not connected")
async for message in self._websocket:
try:
data = json.loads(message)
except json.JSONDecodeError:
logger.warning(f"{self} received non-JSON message: {message}")
continue
await self._handle_message(data)
async def _handle_message(self, message: dict[str, Any]):
"""Branch on xAI STT event type."""
msg_type = message.get("type")
if msg_type == "transcript.created":
self._session_ready.set()
logger.debug(f"{self} xAI STT session ready")
elif msg_type == "transcript.partial":
await self._handle_transcript(message)
elif msg_type == "transcript.done":
if message.get("text"):
await self._push_final_transcript(message, speech_final=True)
elif msg_type == "error":
await self.push_error(
error_msg=f"xAI STT error: {message.get('message', message)}",
exception=Exception(message),
)
else:
logger.debug(f"{self} unhandled xAI STT message: {message}")
async def _handle_transcript(self, message: dict[str, Any]):
text = message.get("text", "")
if not text:
return
is_final = bool(message.get("is_final"))
speech_final = bool(message.get("speech_final"))
language = self._language_for_frame()
if is_final:
await self._push_final_transcript(
message, speech_final=speech_final, language=language, text=text
)
else:
await self.push_frame(
InterimTranscriptionFrame(
text,
self._user_id,
time_now_iso8601(),
language,
result=message,
)
)
async def _push_final_transcript(
self,
message: dict[str, Any],
*,
speech_final: bool,
language: Language | None = None,
text: str | None = None,
):
text = text if text is not None else message.get("text", "")
if not text:
return
language = language if language is not None else self._language_for_frame()
await self.push_frame(
TranscriptionFrame(
text,
self._user_id,
time_now_iso8601(),
language,
result=message,
finalized=speech_final,
)
)
await self._trace_transcription(text, True, language)
if speech_final:
await self.stop_processing_metrics()
def _language_for_frame(self) -> Language:
"""Return a Language enum suitable for transcription frames.
Settings stores the service-specific string (e.g. ``"en"``); frames
carry the enum value.
"""
lang = self._settings.language
if isinstance(lang, Language):
return lang
if isinstance(lang, str):
try:
return Language(lang)
except ValueError:
return Language.EN
return Language.EN
@traced_stt
async def _trace_transcription(self, transcript: str, is_final: bool, language: Language):
"""Record transcription event for tracing."""
pass

View File

@@ -6,22 +6,48 @@
"""xAI text-to-speech service implementation.
Uses xAI's HTTP TTS endpoint documented at:
https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
Provides two TTS services against xAI's voice API:
- :class:`XAIHttpTTSService` uses the batch HTTP endpoint at
``https://api.x.ai/v1/tts``.
- :class:`XAITTSService` uses the streaming WebSocket endpoint at
``wss://api.x.ai/v1/tts``.
See https://docs.x.ai/developers/rest-api-reference/inference/voice.
"""
import base64
import json
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import aiohttp
from loguru import logger
from pipecat.frames.frames import ErrorFrame, Frame, TTSAudioRawFrame
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
ErrorFrame,
Frame,
StartFrame,
TTSAudioRawFrame,
TTSStoppedFrame,
)
from pipecat.services.settings import TTSSettings
from pipecat.services.tts_service import TTSService
from pipecat.services.tts_service import InterruptibleTTSService, TTSService
from pipecat.transcriptions.language import Language, resolve_language
from pipecat.utils.tracing.service_decorators import traced_tts
try:
from websockets.asyncio.client import connect as websocket_connect
from websockets.protocol import State
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use XAITTSService, you need to `pip install pipecat-ai[xai]`.")
raise Exception(f"Missing module: {e}")
def language_to_xai_language(language: Language) -> str | None:
"""Convert a Language enum to xAI language code.
@@ -214,3 +240,249 @@ class XAIHttpTTSService(TTSService):
)
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")
@dataclass
class XAIWebsocketTTSSettings(TTSSettings):
"""Settings for XAITTSService (WebSocket streaming)."""
pass
class XAITTSService(InterruptibleTTSService):
"""xAI streaming text-to-speech service.
Connects to xAI's WebSocket TTS endpoint and streams audio chunks back as
they are synthesized. Text can be sent incrementally via ``text.delta``
messages and each utterance is terminated with ``text.done``. The server
responds with ``audio.delta`` chunks followed by an ``audio.done`` message.
Audio parameters (voice, language, codec, sample rate, bit rate) are passed
as query string parameters on the WebSocket URL; changing any of them at
runtime reconnects the WebSocket.
"""
Settings = XAIWebsocketTTSSettings
_settings: Settings
def __init__(
self,
*,
api_key: str,
base_url: str = "wss://api.x.ai/v1/tts",
sample_rate: int | None = None,
codec: str = "pcm",
settings: Settings | None = None,
**kwargs,
):
"""Initialize the xAI WebSocket TTS service.
Args:
api_key: xAI API key for authentication.
base_url: xAI TTS WebSocket endpoint. Defaults to
``wss://api.x.ai/v1/tts``.
sample_rate: Output audio sample rate in Hz. If None, uses the
pipeline default.
codec: Output audio codec. One of ``pcm``, ``wav``, ``mulaw``,
``alaw``. Defaults to ``pcm`` so emitted ``TTSAudioRawFrame``
objects need no decoding downstream.
settings: Runtime-updatable settings.
**kwargs: Additional arguments passed to parent
``InterruptibleTTSService``.
"""
default_settings = self.Settings(
model=None,
voice="eve",
language=Language.EN,
)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
push_start_frame=True,
push_stop_frames=True,
sample_rate=sample_rate,
settings=default_settings,
**kwargs,
)
self._api_key = api_key
self._base_url = base_url
self._codec = codec
self._receive_task = None
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics."""
return True
def language_to_service_language(self, language: Language) -> str | None:
"""Convert a Language enum to xAI language format."""
return language_to_xai_language(language)
async def start(self, frame: StartFrame):
"""Start the xAI WebSocket TTS service."""
await super().start(frame)
await self._connect()
async def stop(self, frame: EndFrame):
"""Stop the xAI WebSocket TTS service."""
await super().stop(frame)
await self._disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the xAI WebSocket TTS service."""
await super().cancel(frame)
await self._disconnect()
async def _connect(self):
await super()._connect()
await self._connect_websocket()
if self._websocket and not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
async def _disconnect(self):
await super()._disconnect()
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
await self._disconnect_websocket()
async def _update_settings(self, delta: TTSSettings) -> dict[str, Any]:
"""Apply a settings delta. Reconnects if any URL-baked field changes."""
changed = await super()._update_settings(delta)
if changed:
await self._disconnect()
await self._connect()
return changed
def _build_url(self) -> str:
language = self._settings.language
if isinstance(language, Language):
language_value = language_to_xai_language(language) or language.value
else:
language_value = str(language) if language is not None else "auto"
params: dict[str, Any] = {
"voice": self._settings.voice,
"language": language_value,
"codec": self._codec,
"sample_rate": self.sample_rate,
}
return f"{self._base_url}?{urlencode(params)}"
async def _connect_websocket(self):
try:
if self._websocket and self._websocket.state is State.OPEN:
return
logger.debug("Connecting to xAI TTS")
url = self._build_url()
headers = {"Authorization": f"Bearer {self._api_key}"}
self._websocket = await websocket_connect(url, additional_headers=headers)
await self._call_event_handler("on_connected")
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
self._websocket = None
await self._call_event_handler("on_connection_error", f"{e}")
async def _disconnect_websocket(self):
try:
await self.stop_all_metrics()
if self._websocket:
logger.debug("Disconnecting from xAI TTS")
await self._websocket.close()
except Exception as e:
await self.push_error(error_msg=f"Error disconnecting from xAI TTS: {e}", exception=e)
finally:
self._websocket = None
await self._call_event_handler("on_disconnected")
def _get_websocket(self):
if self._websocket:
return self._websocket
raise Exception("Websocket not connected")
async def flush_audio(self, context_id: str | None = None):
"""Signal end-of-utterance so xAI begins synthesizing what it has buffered."""
if not self._websocket or self._websocket.state is State.CLOSED:
return
await self._get_websocket().send(json.dumps({"type": "text.done"}))
async def _receive_messages(self):
async for message in self._get_websocket():
if isinstance(message, bytes):
logger.warning(f"{self}: unexpected binary frame from xAI TTS")
continue
try:
msg = json.loads(message)
except json.JSONDecodeError:
logger.error(f"{self}: invalid JSON message: {message}")
continue
msg_type = msg.get("type")
context_id = self.get_active_audio_context_id()
if msg_type == "audio.delta":
audio_b64 = msg.get("delta")
if not audio_b64:
continue
audio = base64.b64decode(audio_b64)
await self.stop_ttfb_metrics()
if context_id:
frame = TTSAudioRawFrame(
audio=audio,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
await self.append_to_audio_context(context_id, frame)
elif msg_type == "audio.done":
await self.stop_all_metrics()
if context_id:
await self.append_to_audio_context(
context_id, TTSStoppedFrame(context_id=context_id)
)
await self.remove_audio_context(context_id)
elif msg_type == "error":
await self.stop_all_metrics()
error_detail = msg.get("message") or msg.get("error") or str(msg)
if context_id:
await self.append_to_audio_context(
context_id, TTSStoppedFrame(context_id=context_id)
)
await self.remove_audio_context(context_id)
await self.push_error(error_msg=f"xAI TTS error: {error_detail}")
else:
logger.debug(f"{self}: unhandled xAI message type: {msg_type}")
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame, None]:
"""Generate TTS audio from text using xAI's streaming WebSocket API."""
logger.debug(f"{self}: Generating TTS [{text}]")
try:
if not self._websocket or self._websocket.state is State.CLOSED:
await self._connect()
try:
await self._get_websocket().send(json.dumps({"type": "text.delta", "delta": text}))
await self.start_tts_usage_metrics(text)
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")
yield TTSStoppedFrame(context_id=context_id)
await self._disconnect()
await self._connect()
return
yield None
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")

View File

@@ -11,9 +11,15 @@ from .transcription_user_turn_start_strategy import TranscriptionUserTurnStartSt
from .vad_user_turn_start_strategy import VADUserTurnStartStrategy
from .wake_phrase_user_turn_start_strategy import WakePhraseUserTurnStartStrategy
try:
from .krisp_viva_ip_user_turn_start_strategy import KrispVivaIPUserTurnStartStrategy
except ImportError:
KrispVivaIPUserTurnStartStrategy = None
__all__ = [
"BaseUserTurnStartStrategy",
"ExternalUserTurnStartStrategy",
"KrispVivaIPUserTurnStartStrategy",
"MinWordsUserTurnStartStrategy",
"TranscriptionUserTurnStartStrategy",
"UserTurnStartedParams",

View File

@@ -101,7 +101,7 @@ class BaseUserTurnStartStrategy(BaseObject):
"""Reset the strategy to its initial state."""
pass
async def process_frame(self, frame: Frame) -> ProcessFrameResult:
async def process_frame(self, frame: Frame) -> ProcessFrameResult | None:
"""Process an incoming frame.
Subclasses should override this to implement logic that decides whether
@@ -111,8 +111,8 @@ class BaseUserTurnStartStrategy(BaseObject):
frame: The frame to be processed.
Returns:
A ProcessFrameResult indicating the outcome. Subclasses that return
None are treated as CONTINUE for backward compatibility.
A ProcessFrameResult indicating the outcome, or None (treated as
CONTINUE for backward compatibility).
"""
pass

View File

@@ -0,0 +1,282 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""User turn start strategy using Krisp Interruption Prediction (IP).
This strategy uses Krisp's IP model to distinguish genuine user interruptions
from backchannels (e.g. "uh-huh", "yeah"). Instead of triggering a user turn
on every VAD speech event, it collects audio after VAD detects speech and runs
the IP model to predict whether the speech is a real interruption.
Only when the IP model's probability exceeds the configured threshold is
``trigger_user_turn_started()`` called. This prevents the bot from being
interrupted by brief acknowledgements or filler words.
"""
import os
import numpy as np
from loguru import logger
from pipecat.audio.krisp_instance import (
KrispVivaSDKManager,
int_to_krisp_frame_duration,
int_to_krisp_sample_rate,
)
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
InputAudioRawFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.turns.types import ProcessFrameResult
from pipecat.turns.user_start.base_user_turn_start_strategy import BaseUserTurnStartStrategy
try:
import krisp_audio
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error(
"In order to use KrispVivaIPUserTurnStartStrategy, you need to install krisp_audio."
)
raise Exception(f"Missing module: {e}")
class KrispVivaIPUserTurnStartStrategy(BaseUserTurnStartStrategy):
"""User turn start strategy using Krisp VIVA Interruption Prediction.
When VAD detects user speech, this strategy feeds audio frames into
the Krisp VIVA IP model. The model outputs a probability indicating
whether the speech is a genuine interruption (as opposed to a
backchannel). A user turn is triggered only when this probability
exceeds the configured threshold.
This strategy is designed to work alongside other start strategies
(e.g. ``TranscriptionUserTurnStartStrategy`` as a fallback) via the
strategy list in ``UserTurnStrategies``.
Example::
from pipecat.turns.user_start import KrispVivaIPUserTurnStartStrategy
strategies = UserTurnStrategies(
start=[
KrispVivaIPUserTurnStartStrategy(
model_path="/path/to/ip_model.kef",
threshold=0.5,
),
TranscriptionUserTurnStartStrategy(),
],
)
"""
def __init__(
self,
*,
model_path: str | None = None,
threshold: float = 0.5,
frame_duration_ms: int = 20,
api_key: str = "",
**kwargs,
):
"""Initialize the Krisp VIVA IP user turn start strategy.
Args:
model_path: Path to the Krisp VIVA IP model file (.kef). If None,
uses the KRISP_VIVA_IP_MODEL_PATH environment variable.
threshold: IP probability threshold (0.0 to 1.0). When the model's
output exceeds this value, the speech is classified as a genuine
interruption.
frame_duration_ms: Frame duration in milliseconds for IP processing.
Supported values: 10, 15, 20, 30, 32.
api_key: Krisp SDK API key. If empty, falls back to the
KRISP_VIVA_API_KEY environment variable.
**kwargs: Additional arguments passed to BaseUserTurnStartStrategy.
"""
super().__init__(**kwargs)
self._threshold = threshold
self._frame_duration_ms = frame_duration_ms
self._api_key = api_key
self._model_path = model_path or os.getenv("KRISP_VIVA_IP_MODEL_PATH")
if not self._model_path:
raise ValueError(
"IP model path must be provided via model_path or "
"KRISP_VIVA_IP_MODEL_PATH environment variable."
)
if not self._model_path.endswith(".kef"):
raise ValueError("Model is expected with .kef extension")
if not os.path.isfile(self._model_path):
raise FileNotFoundError(f"IP model file not found: {self._model_path}")
self._sdk_acquired = False
self._ip_session = None
self._samples_per_frame: int | None = None
self._sample_rate: int | None = None
# State tracking
self._speech_active = False
self._audio_buffer = bytearray()
self._decision_made = False
# Acquire SDK
try:
KrispVivaSDKManager.acquire(api_key=api_key)
self._sdk_acquired = True
except Exception as e:
raise RuntimeError(f"Failed to initialize Krisp SDK: {e}")
async def cleanup(self):
"""Release Krisp SDK resources."""
if self._sdk_acquired:
try:
self._ip_session = None
KrispVivaSDKManager.release()
self._sdk_acquired = False
except Exception as e:
logger.error(f"Error cleaning up Krisp VIVA IP strategy: {e}", exc_info=True)
def _ensure_session(self, sample_rate: int):
"""Create or re-create the IP session when sample rate changes.
Args:
sample_rate: Audio sample rate in Hz.
"""
if self._sample_rate == sample_rate and self._ip_session is not None:
return
self._sample_rate = sample_rate
self._samples_per_frame = int((sample_rate * self._frame_duration_ms) / 1000)
model_info = krisp_audio.ModelInfo()
model_info.path = self._model_path
ip_cfg = krisp_audio.IpSessionConfig()
ip_cfg.inputSampleRate = int_to_krisp_sample_rate(sample_rate)
ip_cfg.inputFrameDuration = int_to_krisp_frame_duration(self._frame_duration_ms)
ip_cfg.modelInfo = model_info
self._ip_session = krisp_audio.IpFloat.create(ip_cfg)
logger.debug(f"Krisp VIVA IP session created (sample_rate={sample_rate})")
def _reset_state(self):
"""Reset speech tracking state for the next candidate interruption."""
self._speech_active = False
self._audio_buffer.clear()
self._decision_made = False
async def reset(self):
"""Reset the strategy to its initial state."""
await super().reset()
self._reset_state()
async def process_frame(self, frame: Frame) -> ProcessFrameResult:
"""Process a frame to detect genuine user interruptions.
On ``VADUserStartedSpeakingFrame``, begins collecting audio.
On ``InputAudioRawFrame``, feeds audio through the IP model and
triggers a user turn if the interruption probability exceeds the
threshold.
On ``VADUserStoppedSpeakingFrame`` or ``BotStoppedSpeakingFrame``,
resets the candidate state.
Args:
frame: The incoming frame.
Returns:
STOP if a genuine interruption was detected, CONTINUE otherwise.
"""
if isinstance(frame, VADUserStartedSpeakingFrame):
return await self._handle_vad_started(frame)
elif isinstance(frame, InputAudioRawFrame):
return await self._handle_audio(frame)
elif isinstance(frame, (VADUserStoppedSpeakingFrame, BotStoppedSpeakingFrame)):
return await self._handle_reset(frame)
return ProcessFrameResult.CONTINUE
async def _handle_vad_started(self, frame: VADUserStartedSpeakingFrame) -> ProcessFrameResult:
"""Begin collecting audio for interruption classification.
Args:
frame: The VAD speech-start frame.
Returns:
Always CONTINUE; the decision is deferred until enough audio is processed.
"""
logger.trace("Krisp VIVA IP: VAD speech started, collecting audio for classification")
self._speech_active = True
self._audio_buffer.clear()
self._decision_made = False
return ProcessFrameResult.CONTINUE
async def _handle_audio(self, frame: InputAudioRawFrame) -> ProcessFrameResult:
"""Feed audio to the IP model and check for genuine interruption.
Args:
frame: Raw audio input frame.
Returns:
STOP if the model detects a genuine interruption, CONTINUE otherwise.
"""
if not self._speech_active or self._decision_made:
return ProcessFrameResult.CONTINUE
self._ensure_session(frame.sample_rate)
if self._ip_session is None or self._samples_per_frame is None:
logger.warning("IP session not ready, skipping frame")
return ProcessFrameResult.CONTINUE
self._audio_buffer.extend(frame.audio)
total_samples = len(self._audio_buffer) // 2 # 2 bytes per int16 sample
num_complete_frames = total_samples // self._samples_per_frame
if num_complete_frames == 0:
return ProcessFrameResult.CONTINUE
complete_samples_count = num_complete_frames * self._samples_per_frame
bytes_to_process = complete_samples_count * 2
audio_to_process = bytes(self._audio_buffer[:bytes_to_process])
self._audio_buffer = self._audio_buffer[bytes_to_process:]
audio_int16 = np.frombuffer(audio_to_process, dtype=np.int16)
audio_float32 = audio_int16.astype(np.float32) / 32768.0
frames = audio_float32.reshape(-1, self._samples_per_frame)
for ip_frame in frames:
ip_prob = self._ip_session.process(ip_frame.tolist(), self._speech_active)
if ip_prob >= self._threshold:
logger.debug(
f"Krisp VIVA IP: genuine interruption detected (prob={ip_prob:.3f}, "
f"threshold={self._threshold})"
)
self._decision_made = True
await self.trigger_user_turn_started()
return ProcessFrameResult.STOP
return ProcessFrameResult.CONTINUE
async def _handle_reset(
self, frame: VADUserStoppedSpeakingFrame | BotStoppedSpeakingFrame
) -> ProcessFrameResult:
"""Reset state when the candidate interruption window ends.
Args:
frame: The frame signaling end of speech or bot output.
Returns:
Always CONTINUE.
"""
if self._speech_active:
logger.trace("Krisp VIVA IP: speech segment ended, resetting state")
self._reset_state()
return ProcessFrameResult.CONTINUE

View File

@@ -89,7 +89,7 @@ class BaseUserTurnStopStrategy(BaseObject):
"""Reset the strategy to its initial state."""
pass
async def process_frame(self, frame: Frame) -> ProcessFrameResult:
async def process_frame(self, frame: Frame) -> ProcessFrameResult | None:
"""Process an incoming frame to decide whether the user stopped speaking.
Subclasses should override this to implement logic that decides whether
@@ -99,8 +99,8 @@ class BaseUserTurnStopStrategy(BaseObject):
frame: The frame to be analyzed.
Returns:
A ProcessFrameResult indicating the outcome. Subclasses that return
None are treated as CONTINUE for backward compatibility.
A ProcessFrameResult indicating the outcome, or None (treated as
CONTINUE for backward compatibility).
"""
pass

View File

@@ -7,7 +7,6 @@
"""Speech timeout-based user turn stop strategy."""
import asyncio
import time
from loguru import logger
@@ -25,20 +24,25 @@ from pipecat.utils.asyncio.task_manager import BaseTaskManager
class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
"""User turn stop strategy that uses a configurable timeout to determine if the user is done speaking.
"""User turn stop strategy using two independent timers after VAD stop.
After the user stops speaking (detected by VAD), this strategy waits for a
configurable timeout before triggering the end of the user's turn. The
timeout accounts for two factors:
After the user stops speaking (detected by VAD), this strategy runs two
independent timers. The user turn stop is triggered only when both have
finished and at least one transcript has been received:
- user_speech_timeout: Time to wait for the user to potentially say more
after they pause.
- stt_timeout: The P99 time for the STT service to return a transcription
after the user stops speaking, adjusted by the VAD stop_secs.
- user_speech_timeout: Policy floor — the window in which the user may
resume speaking after a pause. Always runs to completion.
- stt_timeout: Safety net for STT latency — the P99 time for the STT
service to return a final transcript after VAD stop, adjusted by the
VAD stop_secs. Short-circuited when the STT service emits a finalized
transcript (TranscriptionFrame.finalized=True), since finalization
means STT has nothing more to send.
For services that support finalization (TranscriptionFrame.finalized=True),
the turn can be triggered immediately once the finalized transcript is
received and the user resume speaking timeout has elapsed.
Fallback: when a transcript arrives without a VAD stop event, the
user_speech_timeout timer measures inactivity since the last transcript
(rearmed on each transcript). stt_timeout has no meaning here since it
is defined relative to VAD stop, and STT has already emitted a
transcript — so the stt wait is marked done immediately.
"""
def __init__(self, *, user_speech_timeout: float = 0.6, **kwargs):
@@ -59,8 +63,11 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
self._vad_user_speaking = False
self._transcript_finalized = False
self._vad_stopped_time: float | None = None
self._timeout_task: asyncio.Task | None = None
self._timeout_expired: bool = False
self._user_speech_timeout_task: asyncio.Task | None = None
self._stt_timeout_task: asyncio.Task | None = None
self._user_speech_wait_done: bool = False
self._stt_wait_done: bool = False
async def reset(self):
"""Reset the strategy to its initial state."""
@@ -69,10 +76,9 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
self._vad_user_speaking = False
self._transcript_finalized = False
self._vad_stopped_time = None
self._timeout_expired = False
if self._timeout_task:
await self.task_manager.cancel_task(self._timeout_task)
self._timeout_task = None
self._user_speech_wait_done = False
self._stt_wait_done = False
await self._cancel_all_tasks()
async def setup(self, task_manager: BaseTaskManager):
"""Initialize the strategy with the given task manager.
@@ -85,9 +91,7 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
async def cleanup(self):
"""Cleanup the strategy."""
await super().cleanup()
if self._timeout_task:
await self.task_manager.cancel_task(self._timeout_task)
self._timeout_task = None
await self._cancel_all_tasks()
async def process_frame(self, frame: Frame) -> ProcessFrameResult:
"""Process an incoming frame to update strategy state.
@@ -105,8 +109,10 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
self._stt_timeout = frame.ttfs_p99_latency
self._stop_secs_warned = False
elif isinstance(frame, VADUserStartedSpeakingFrame):
logger.debug(f"{self} VADUserStartedSpeakingFrame received")
await self._handle_vad_user_started_speaking(frame)
elif isinstance(frame, VADUserStoppedSpeakingFrame):
logger.debug(f"{self} VADUserStoppedSpeakingFrame received")
await self._handle_vad_user_stopped_speaking(frame)
elif isinstance(frame, TranscriptionFrame):
await self._handle_transcription(frame)
@@ -118,11 +124,9 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
self._vad_user_speaking = True
self._transcript_finalized = False
self._vad_stopped_time = None
self._timeout_expired = False
# Cancel any pending timeout
if self._timeout_task:
await self.task_manager.cancel_task(self._timeout_task)
self._timeout_task = None
self._user_speech_wait_done = False
self._stt_wait_done = False
await self._cancel_all_tasks()
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame):
"""Handle when the VAD indicates the user has stopped speaking."""
@@ -150,59 +154,69 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
f"user_turn_stop_timeout parameter in the LLMUserAggregatorParams."
)
# Start the timeout task
timeout = self._calculate_timeout()
self._timeout_task = self.task_manager.create_task(
self._timeout_handler(timeout), f"{self}::_timeout_handler"
)
# Make sure the task is scheduled.
# user_speech_timeout is the policy floor and always runs. A prior
# fallback-mode run of the same timer is superseded here.
await self._restart_user_speech_timer()
# stt_timeout is a safety net. Short-circuit it if the transcript is
# already finalized, or if the VAD stop_secs already covered it.
self._stt_wait_done = False
effective_stt_wait = max(0.0, self._stt_timeout - self._stop_secs)
if self._transcript_finalized or effective_stt_wait <= 0:
self._stt_wait_done = True
else:
self._stt_timeout_task = self.task_manager.create_task(
self._stt_timeout_handler(effective_stt_wait),
f"{self}::_stt_timeout_handler",
)
# Make sure the tasks are scheduled.
await asyncio.sleep(0)
async def _handle_transcription(self, frame: TranscriptionFrame):
"""Handle user transcription."""
self._text += frame.text
if frame.finalized:
self._transcript_finalized = True
# For finalized transcripts, check if we can trigger early
# Short-circuit the stt_timeout safety net: STT has told us
# there's nothing more coming.
if not self._stt_wait_done:
self._stt_wait_done = True
if self._stt_timeout_task:
await self.task_manager.cancel_task(self._stt_timeout_task)
self._stt_timeout_task = None
# If both waits are already done, the turn was waiting on text —
# trigger now.
if self._user_speech_wait_done and self._stt_wait_done:
await self._maybe_trigger_user_turn_stopped()
elif self._timeout_expired:
# The p99 timeout already elapsed without a transcript. Now that
# we have one, trigger the turn stop immediately.
await self.trigger_user_turn_stopped()
return
# Fallback: handle transcripts when no VAD stop was received.
# This handles edge cases where transcripts arrive without VAD firing.
# _vad_stopped_time is None means VAD stopped hasn't been received yet.
# In fallback mode, reset timeout on each transcript to wait for inactivity.
# Fallback: transcript arrived without a VAD stop. Measure inactivity
# since the last transcript with the user_speech_timer. stt_timeout
# has no meaning here (it's defined relative to VAD stop), so mark
# the stt wait done immediately.
if not self._vad_user_speaking and self._vad_stopped_time is None:
# Cancel existing fallback timeout if any
if self._timeout_task:
await self.task_manager.cancel_task(self._timeout_task)
timeout = self._calculate_timeout()
self._timeout_task = self.task_manager.create_task(
self._timeout_handler(timeout), f"{self}::_timeout_handler"
)
# Make sure the task is scheduled.
await asyncio.sleep(0)
self._stt_wait_done = True
await self._restart_user_speech_timer()
def _calculate_timeout(self) -> float:
"""Calculate the timeout value based on current state.
async def _restart_user_speech_timer(self):
"""Cancel any running user_speech timer and start a fresh one."""
if self._user_speech_timeout_task:
await self.task_manager.cancel_task(self._user_speech_timeout_task)
self._user_speech_timeout_task = None
self._user_speech_wait_done = False
self._user_speech_timeout_task = self.task_manager.create_task(
self._user_speech_timeout_handler(self._user_speech_timeout),
f"{self}::_user_speech_timeout_handler",
)
# Make sure the task is scheduled so it can't be cancelled before
# starting (which would leave its coroutine un-awaited).
await asyncio.sleep(0)
Returns:
The timeout in seconds to wait after VAD stopped speaking.
"""
# Adjust STT timeout by VAD stop_secs since that time has already elapsed
effective_stt_wait = max(0, self._stt_timeout - self._stop_secs)
# If transcript is already finalized, we don't need to wait for STT
if self._transcript_finalized:
return self._user_speech_timeout
return max(effective_stt_wait, self._user_speech_timeout)
async def _timeout_handler(self, timeout: float):
"""Wait for the timeout then trigger user turn stopped if conditions met.
async def _user_speech_timeout_handler(self, timeout: float):
"""Wait user_speech_timeout then attempt to trigger user turn stopped.
Args:
timeout: The timeout in seconds to wait.
@@ -212,36 +226,46 @@ class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
except asyncio.CancelledError:
return
finally:
self._timeout_task = None
self._user_speech_timeout_task = None
self._timeout_expired = True
self._user_speech_wait_done = True
await self._maybe_trigger_user_turn_stopped()
async def _stt_timeout_handler(self, timeout: float):
"""Wait stt_timeout then attempt to trigger user turn stopped.
Args:
timeout: The timeout in seconds to wait.
"""
try:
await asyncio.sleep(timeout)
except asyncio.CancelledError:
return
finally:
self._stt_timeout_task = None
self._stt_wait_done = True
await self._maybe_trigger_user_turn_stopped()
async def _maybe_trigger_user_turn_stopped(self):
"""Trigger user turn stopped if conditions are met.
"""Trigger user turn stopped if all required conditions are met.
Conditions:
- User is not currently speaking
- We have transcription text
- Either the timeout has elapsed OR we have a finalized transcript
and user_speech_timeout has elapsed
Both timers must be done (stt is marked done immediately on the
fallback path and when finalization short-circuits the safety net),
the user must not be currently speaking, and at least one transcript
must have been received.
"""
if self._vad_user_speaking or not self._text:
return
# For finalized transcripts, check if user_speech_timeout has elapsed.
# If elapsed, trigger user turn stopped immediately. Else, wait for user resume
# speaking timeout.
if self._transcript_finalized and self._vad_stopped_time is not None:
elapsed = time.time() - self._vad_stopped_time
if elapsed >= self._user_speech_timeout:
# Cancel any remaining timeout since we're triggering now
if self._timeout_task:
await self.task_manager.cancel_task(self._timeout_task)
self._timeout_task = None
await self.trigger_user_turn_stopped()
return
# For non-finalized, only trigger if timeout task has completed
if self._timeout_task is None:
if self._user_speech_wait_done and self._stt_wait_done:
await self.trigger_user_turn_stopped()
async def _cancel_all_tasks(self):
"""Cancel any running timer tasks and clear the handles."""
if self._user_speech_timeout_task:
await self.task_manager.cancel_task(self._user_speech_timeout_task)
self._user_speech_timeout_task = None
if self._stt_timeout_task:
await self.task_manager.cancel_task(self._stt_timeout_task)
self._stt_timeout_task = None

View File

@@ -26,7 +26,7 @@ from pipecat.frames.frames import (
LLMRunFrame,
LLMTextFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
# Turn completion markers
USER_TURN_COMPLETE_MARKER = ""
@@ -178,7 +178,7 @@ class UserTurnCompletionConfig:
return self.incomplete_long_prompt or DEFAULT_INCOMPLETE_LONG_PROMPT
class UserTurnCompletionLLMServiceMixin:
class UserTurnCompletionLLMServiceMixin(FrameProcessor):
"""Mixin that adds turn completion detection to LLM services.
This mixin provides methods to push LLM text with turn completion detection.
@@ -292,7 +292,7 @@ class UserTurnCompletionLLMServiceMixin:
# Push through pipeline to trigger LLM response
await self.push_frame(
LLMMessagesAppendFrame(messages=[{"role": "system", "content": prompt}])
LLMMessagesAppendFrame(messages=[{"role": "developer", "content": prompt}])
)
await self.push_frame(LLMRunFrame())

View File

@@ -20,7 +20,11 @@ if TYPE_CHECKING:
from loguru import logger
from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage
from pipecat.processors.aggregators.llm_context import (
LLMContext,
LLMContextMessage,
LLMSpecificMessage,
)
# Fallback timeout (seconds) used when summarization_timeout is None.
DEFAULT_SUMMARIZATION_TIMEOUT = 120.0
@@ -269,7 +273,7 @@ class LLMMessagesToSummarize:
last_summarized_index: Index of the last message being summarized
"""
messages: list[dict]
messages: list[LLMContextMessage]
last_summarized_index: int
@@ -415,7 +419,7 @@ class LLMContextSummarizationUtil:
@staticmethod
def _get_earliest_function_call_not_resolved_in_range(
messages: list[dict], start_idx: int, summary_end: int
messages: list[LLMContextMessage], start_idx: int, summary_end: int
) -> int:
"""Find the earliest message index with incomplete function calls.
@@ -470,9 +474,10 @@ class LLMContextSummarizationUtil:
if role == "tool":
tool_call_id = msg.get("tool_call_id")
if tool_call_id and tool_call_id in pending_tool_calls:
if not LLMContextSummarizationUtil._is_tool_message_pending(
msg.get("content", "")
):
content = msg.get("content", "")
if not isinstance(content, str):
content = ""
if not LLMContextSummarizationUtil._is_tool_message_pending(content):
pending_tool_calls.pop(tool_call_id)
# Check for async tool completion — a developer message with
@@ -480,7 +485,10 @@ class LLMContextSummarizationUtil:
# async result has arrived and the call is now resolved.
if role == "developer":
try:
parsed = json.loads(msg.get("content", ""))
content = msg.get("content", "")
if not isinstance(content, str):
continue
parsed = json.loads(content)
if (
isinstance(parsed, dict)
and parsed.get("type") == "async_tool"

View File

@@ -58,7 +58,7 @@ class FrameQueue(asyncio.Queue):
Returns:
True if at least one enqueued frame is an instance of ``frame_type``.
"""
for item in self._queue:
for item in self._queue: # pyright: ignore[reportAttributeAccessIssue]
if isinstance(self._frame_getter(item), frame_type):
return True
return False

View File

@@ -234,7 +234,7 @@ class TextPartForConcatenation:
includes_inter_part_spaces: bool
def __str__(self):
return f"{self.name}(text: [{self.text}], includes_inter_part_spaces: {self.includes_inter_part_spaces})"
return f"{type(self).__name__}(text: [{self.text}], includes_inter_part_spaces: {self.includes_inter_part_spaces})"
def concatenate_aggregated_text(text_parts: list[TextPartForConcatenation]) -> str:

View File

@@ -125,7 +125,7 @@ class BaseTextAggregator(ABC):
"""
pass
# Make this a generator to satisfy type checker
yield # pragma: no cover
yield # pyright: ignore[reportReturnType] # pragma: no cover
@abstractmethod
async def flush(self) -> Aggregation | None:

View File

@@ -273,7 +273,7 @@ class PatternPairAggregator(SimpleTextAggregator):
# Which is why we base the return on the first found.
if start_count > end_count:
start_index = text.find(start)
return [start_index, pattern_info]
return (start_index, pattern_info)
return None

View File

@@ -440,7 +440,7 @@ def add_openai_realtime_span_attributes(
if isinstance(tool, dict) and "name" in tool:
tool_names.append(tool["name"])
elif hasattr(tool, "name"):
tool_names.append(tool.name)
tool_names.append(getattr(tool, "name"))
elif isinstance(tool, dict) and "function" in tool and "name" in tool["function"]:
tool_names.append(tool["function"]["name"])
@@ -455,7 +455,7 @@ def add_openai_realtime_span_attributes(
if function_calls:
call = function_calls[0]
if hasattr(call, "name"):
span.set_attribute("function_calls.first_name", call.name)
span.set_attribute("function_calls.first_name", getattr(call, "name"))
elif isinstance(call, dict) and "name" in call:
span.set_attribute("function_calls.first_name", call["name"])