Compare commits

...

39 Commits

Author SHA1 Message Date
vipyne
7ecba34c40 POC: Add MoQ transport 2026-05-15 16:59:07 -05:00
Mark Backman
c6ea6c6522 Merge pull request #4500 from pipecat-ai/mb/update-gradium-endpoints
Update Gradium STT/TTS endpoints to region-neutral URLs
2026-05-15 15:59:14 -04:00
Mark Backman
58a22aeeb1 Add changelog for #4500 2026-05-15 15:19:39 -04:00
Mark Backman
5403aa56e4 Remove Gradium endpoint overrides from voice example
Drop the explicit US-region URLs so the example picks up the new
region-neutral defaults in GradiumSTTService and GradiumTTSService.
2026-05-15 15:17:12 -04:00
Mark Backman
0e0d76d020 Update Gradium endpoints to region-neutral URLs
Drop the EU-region default from the STT/TTS WebSocket URLs in favor of
the generic api.gradium.ai endpoint, and remove the explicit overrides
from the examples so they pick up the new defaults.
2026-05-15 15:02:05 -04:00
Aleix Conchillo Flaqué
ea296babe9 Merge pull request #4498 from pipecat-ai/changelog-1.2.0
Release 1.2.0 - Changelog Update
2026-05-14 14:47:47 -07:00
aconchillo
b13af2b053 Update changelog for version 1.2.0 2026-05-14 21:45:36 +00:00
Aleix Conchillo Flaqué
7b6d878f07 update uv.lock 2026-05-14 14:41:38 -07:00
Aleix Conchillo Flaqué
8e405f15aa changelog: fix 4446.change.md file name 2026-05-14 14:38:54 -07:00
Aleix Conchillo Flaqué
44a40e8eb2 Merge pull request #4497 from pipecat-ai/aleix/fix-tts-context-id-fallback
Fall back to _turn_context_id in get_active_audio_context_id
2026-05-14 13:34:34 -07:00
Aleix Conchillo Flaqué
ea97cb1a78 Add changelog for #4497 2026-05-14 13:22:50 -07:00
Aleix Conchillo Flaqué
22650b1b56 Move QwenLLMService model into Settings in the qwen example
Mirrors the deprecation in ``QwenLLMService.__init__``: ``model`` should
be passed via ``settings=QwenLLMService.Settings(model=...)`` instead of
as a direct constructor arg.
2026-05-14 13:22:07 -07:00
Aleix Conchillo Flaqué
b76831e677 Fall back to _turn_context_id in get_active_audio_context_id
TTS services whose wire protocol does not echo the context_id back on
incoming audio (Sarvam, Smallest, Soniox, Inworld, ...) call
``get_active_audio_context_id()`` to tag each chunk. That accessor
returned only ``_playing_context_id`` — the playback-side cursor set
asynchronously by ``_audio_context_task_handler`` when it pops a context
off the serialization queue.

Result: incoming audio that arrived in the gap between contexts or at
the very start of a turn (before the playback loop popped) had
``context_id=None`` and was dropped with
``unable to append audio to context: no context ID provided``.

Fall back to ``_turn_context_id`` (the synthesis-side cursor, set as
soon as the turn's context is created) so the gap is covered without
prematurely nulling the playback cursor.
2026-05-14 13:22:00 -07:00
Mark Backman
b57111743f Merge pull request #4495 from pipecat-ai/mb/soniox-stt-lang-counter 2026-05-14 15:57:31 -04:00
Mark Backman
dcbb0070c9 Add changelog for Soniox language selection 2026-05-14 15:42:43 -04:00
Mark Backman
73278d3309 Use majority language for Soniox transcripts 2026-05-14 15:18:43 -04:00
Mark Backman
49bda11ae8 Merge pull request #4482 from pipecat-ai/mb/soniox-stt-token-language
Propagate Soniox token language
2026-05-13 16:28:56 -04:00
Aleix Conchillo Flaqué
07640582ce Merge pull request #4467 from pipecat-ai/aleix/fix-tts-ttfb-tracing
Fix metrics.ttfb and partial output on TTS/STT/LLM OpenTelemetry spans
2026-05-13 13:10:52 -07:00
Mark Backman
078af6969a Merge pull request #4473 from timofey-TK/inworld-tts-v2
Add support for Inworld TTS v2 fields
2026-05-13 15:32:16 -04:00
Mark Backman
9f40ba21c2 Add changelog for Soniox language fix 2026-05-13 15:26:10 -04:00
Mark Backman
82f0896d6a Propagate Soniox token language 2026-05-13 15:23:22 -04:00
kompfner
7e4cd23de4 Merge pull request #4474 from pipecat-ai/pk/inworld-realtime-tools
Extend cancel_on_interruption=False to Inworld Realtime (best-effort + warning)
2026-05-13 15:12:34 -04:00
TimTk
97f50c8aa2 Address review: use resolve_language, narrow delivery_mode type, update changelog
- Replace custom LANGUAGE_MAP fallback in language_to_inworld_language with
  resolve_language(language, LANGUAGE_MAP, use_base_code=False) to match the
  pattern used by other services and restore the unverified-language warning
- Tighten delivery_mode type from str to Literal["STABLE", "BALANCED", "CREATIVE"]
- Update changelog entry to mention delivery_mode and language normalization
2026-05-13 21:43:02 +03:00
Mark Backman
08680732f6 Merge pull request #4475 from pipecat-ai/mb/cartesia-korean-fix
Fix Cartesia CJK timestamp spacing
2026-05-13 13:20:42 -04:00
Mark Backman
064b68aa01 Fix Cartesia CJK timestamp spacing 2026-05-13 13:13:40 -04:00
Filipi da Silva Fuchter
b0f8ea7e28 Merge pull request #4477 from pipecat-ai/filipi/nvidia_sagemaker_follow_up
NVidia TTS Sagemaker: Buffering audio to avoid glitches.
2026-05-13 14:06:44 -03:00
filipi87
ad50c8d5d5 Buffering audio to avoid glitches. 2026-05-13 14:01:03 -03:00
Timofey
39e7f9e354 Fix Inworld TTS v2 request fields 2026-05-13 11:17:31 +03:00
Aleix Conchillo Flaqué
7cc7968abb Fix pyright errors in service_decorators.py 2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
52d8008783 Add LLM interruption changelog entry for #4467 2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
a3ce963b54 Capture partial LLM output on interruption
traced_llm only attached the aggregated ``output`` attribute to the
span after the wrapped function returned successfully. When the LLM
call was cancelled mid-stream (e.g. interruption during generation),
the accumulated text was discarded — the span had no ``output``.

Moved the attribute assignment into the ``finally`` block alongside
the existing TTFB write so the partial text we already captured via
the patched ``push_frame`` lands on the span regardless of whether
``f`` returned normally, raised, or was cancelled.
2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
e70ee603b2 Add STT changelog entry for #4467 2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
111e59a7b1 Apply the same span-scope fix to traced_stt
@traced_stt had the same root issue as @traced_tts: the span lifetime
was tied to a per-transcript handler call, which doesn't match the
operation we want to trace. Now uses the __set_name__ pattern to
install:

- A push_frame wrapper that drives one STT span per finalized
  TranscriptionFrame. The span is anchored at speech start
  (VADUserStartedSpeakingFrame.timestamp - start_secs) but lazy-opened
  on the first TranscriptionFrame. Opening earlier (on VAD or
  UserStartedSpeakingFrame) races with TurnTraceObserver._handle_turn_started,
  which runs as a background task via _call_event_handler (sync=False),
  so the span would end up parented to the previous turn. Deferring
  the open to the first TranscriptionFrame avoids that race because
  STT only emits transcripts well after the turn observer has set
  the current turn's context.

- A stop_ttfb_metrics wrapper that closes the span on the TTFB-timeout
  path (called with end_time != None from stt_service.py:566). The
  span is marked stt.timed_out=True and its end_time is pinned to
  the timeout's end_time (= _last_transcript_time) so the duration
  reflects when STT actually stopped responding, not when the timeout
  fired.

Span lifecycle:
- Open: lazy on first TranscriptionFrame of a segment.
- Close (success): finalized=True attaches metrics.ttfb and closes
  the span. Multiple finalized transcripts in a single turn produce
  multiple spans.
- Close (timeout): stop_ttfb_metrics(end_time=...) closes with
  stt.timed_out=True.
- Close (orphan): UserStoppedSpeakingFrame closes any still-open
  span with stt.incomplete=True (covers turns where no finalized
  transcript and no timeout fired).

No changes required outside service_decorators.py — stt_service.py
and every per-service file are untouched.
2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
079282d140 Add changelog for #4467 2026-05-12 20:10:43 -07:00
Aleix Conchillo Flaqué
0ccdd808e6 Fix traced_tts so metrics.ttfb reflects the real TTFB
Previously @traced_tts scoped the span to the lifetime of run_tts(). For
streaming TTS services run_tts() returns as soon as the synthesis request
is sent, long before audio chunks arrive, so:

- The span duration measured the WebSocket-send time, not synthesis time.
- The first synthesis recorded the WS-send duration as metrics.ttfb (via
  the in-progress fallback in FrameProcessorMetrics.ttfb).
- Subsequent syntheses recorded the previous call's TTFB on the current
  span (off-by-one).

The decorator now uses a __set_name__ descriptor to wrap the owning
class's setup() at class definition time. setup() installs per-instance
patches on create_audio_context, append_to_audio_context,
remove_audio_context, on_audio_context_completed, and
reset_active_audio_context. These patches own the span lifetime:

- create_audio_context: open span, set baseline attributes.
- append_to_audio_context: record metrics.ttfb on the first
  TTSAudioRawFrame (when stop_ttfb_metrics has produced a real value),
  end span on appended TTSStoppedFrame.
- on_audio_context_completed: end span on natural completion (handles
  services that auto-push TTSStoppedFrame via push_frame, bypassing
  append_to_audio_context).
- remove_audio_context: safety net for explicit removal paths.
- reset_active_audio_context: interruption hook (always reached from
  _handle_interruption); marks the span tts.interrupted=true only when
  nothing else has closed it.

The run_tts wrapper now only attaches per-call attributes (text,
metrics.character_count) to the already-open span. No changes required
in tts_service.py or in any of the per-service files.
2026-05-12 20:10:43 -07:00
Paul Kompfner
863a1bf177 Add changelog for #4474 2026-05-12 16:04:12 -04:00
Paul Kompfner
58333b2705 Extend cancel_on_interruption=False to InworldRealtimeLLMService (best-effort)
Same async-tool routing approach as #4441: detect async-tool messages in
the LLM context, deliver the final result via the formal tool-result
channel.

Caveat: as of this writing, Inworld Realtime doesn't appear to handle
the resulting delayed tool result reliably, so the routing is
best-effort and the service emits a one-time warning when async-tool
messages are seen. Streamed intermediate results remain unsupported.

Also adds function calling to the realtime-inworld.py example, and
softens the Inworld mention in the #4447 changelog now that the
exclusion is being closed.
2026-05-12 16:03:34 -04:00
TimTk
ecaff1d1eb Fix changelog fragment number 2026-05-12 22:21:59 +03:00
TimTk
9b55d4ddd4 Add support for Inworld TTS v2 fields 2026-05-12 22:13:09 +03:00
92 changed files with 6338 additions and 1010 deletions

5
.gitignore vendored
View File

@@ -61,4 +61,7 @@ docs/api/api
.python-version
# Pipecat
whisker_setup.py
whisker_setup.py
# MoQ transport
*.pem

View File

@@ -7,6 +7,494 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- towncrier release notes start -->
## [1.2.0] - 2026-05-14
### Added
- Added a `session_id` field to `RunnerArguments` so bots can log or trace a
per-session identifier in local development the same way they can in Pipecat
Cloud. The development runner now mints a UUID at every construction site,
and paths that already returned a `sessionId` to the caller (Daily `/start`,
dial-in webhook) share that same UUID with the runner args instead of
generating two. The SmallWebRTC `/api/offer` endpoint also accepts an
optional `session_id` query parameter so the `/sessions/{session_id}/...`
proxy can thread it through.
(PR [#4385](https://github.com/pipecat-ai/pipecat/pull/4385))
- Added a `max_buffer_delay_ms` constructor argument to `CartesiaTTSService`
for controlling Cartesia's server-side text buffering. When unset, Pipecat
picks a sensible default based on `text_aggregation_mode`: `0` in `SENTENCE`
mode (custom buffering — avoids stacking client-side aggregation on top of
Cartesia's default 3000ms server buffer) and unset in `TOKEN` mode
(Cartesia's managed buffering applies). Pass an explicit value (05000ms) to
override.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- Added a `mip_opt_out` constructor argument to `DeepgramTTSService` and
`DeepgramHttpTTSService` so callers can opt out of the Deepgram Model
Improvement Program. When set, the value is forwarded to Deepgram as a query
parameter on the speak request. Defaults to `None`, which preserves the
existing behavior. See https://dpgr.am/deepgram-mip for pricing implications
before enabling.
(PR [#4400](https://github.com/pipecat-ai/pipecat/pull/4400))
- Added an opt-in `add_tool_change_messages` flag to the LLM aggregators (set
via `LLMContextAggregatorPair(..., add_tool_change_messages=True)`) that
appends a developer-role message to the context whenever `LLMSetToolsFrame`
changes the set of advertised standard tools. Helps the LLM stay coherent
across mid-conversation tool changes, mitigating several flavors of
tool-call-related hallucination: calling tools that have been removed,
avoiding tools that have been re-added, and hallucinating output (made-up
answers or tool-call-shaped non-tool-calls) when tools are unavailable.
(PR [#4404](https://github.com/pipecat-ai/pipecat/pull/4404))
- Added `deferred(strategy)` and `DeferredUserTurnStopStrategy` in
`pipecat.turns.user_stop`. Wraps a stop strategy so it fires only the
inference-triggered event and suppresses `on_user_turn_stopped`, leaving
finalization to another strategy in the chain such as
`LLMTurnCompletionUserTurnStopStrategy`.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Added `ExternalUserTurnCompletionStopStrategy` in `pipecat.turns.user_stop`
a generic stop strategy that finalizes the user turn whenever a
`UserTurnInferenceCompletedFrame` arrives, regardless of which component
produced it. `LLMTurnCompletionUserTurnStopStrategy` now extends this base;
future producers (Flux, custom end-of-turn classifiers, etc.) can use the
base directly or subclass it to add producer-specific setup.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Added `on_user_turn_inference_triggered`, a new event on the user turn
controller, processor, aggregator and stop strategies that fires when a
strategy has enough signal to start LLM inference. By default it fires
together with `on_user_turn_stopped`; a gating strategy can fire only the
inference-triggered event and defer finalization to a peer.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Added `FilterIncompleteUserTurnStrategies` in
`pipecat.turns.user_turn_strategies` — a `UserTurnStrategies` specialization
that wraps the detector chain with `deferred(...)` and appends
`LLMTurnCompletionUserTurnStopStrategy` as the finalizer. Common case:
`user_turn_strategies=FilterIncompleteUserTurnStrategies()`. Pass
`config=UserTurnCompletionConfig(...)` to customize timeouts and prompts.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Added `LLMTurnCompletionUserTurnStopStrategy` in `pipecat.turns.user_stop`.
When installed, the strategy gates `on_user_turn_stopped` on a
`UserTurnInferenceCompletedFrame` (a new fieldless system frame emitted by
any component that can judge turn completeness — e.g. the
`UserTurnCompletionLLMServiceMixin` on `✓`). A `finalization_timeout`
provides a safety net if no completion frame ever arrives.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Added first-class RTVI support for the UI Agent Protocol:
- Adds `ui-event`, `ui-snapshot`, and `ui-cancel-task` client-to-server
messages, plus `ui-command` and `ui-task` server-to-client messages, with
paired `*Data` / `*Message` pydantic models.
- Adds built-in command payload models for `Toast`, `Navigate`, `ScrollTo`,
`Highlight`, `Focus`, `Click`, `SetInputValue`, and `SelectText`; matching
default handlers live in `@pipecat-ai/client-react`.
- Adds `RTVIProcessor.on_ui_message` for inbound `ui-event`, `ui-snapshot`,
and `ui-cancel-task` messages.
- Adds five UI pipeline frames, mirroring the `client-message`
frame-and-event pattern: downstream code pushes `RTVIUICommandFrame` /
`RTVIUITaskFrame` for the observer to wrap into outbound `UICommandMessage` /
`UITaskMessage` envelopes, while the processor pushes inbound
`RTVIUIEventFrame`, `RTVIUISnapshotFrame`, and `RTVIUICancelTaskFrame`
alongside `on_ui_message`.
- Bumps the RTVI `PROTOCOL_VERSION` from `1.2.0` to `1.3.0`.
(PR [#4407](https://github.com/pipecat-ai/pipecat/pull/4407))
- AWS Transcribe STT, Polly TTS, Bedrock LLM, and the Bedrock AgentCore
processor now resolve credentials via the standard boto3 provider chain (EC2
instance profiles, EKS pod roles / IRSA, ECS task roles, SSO,
`~/.aws/credentials`) when explicit credentials and `AWS_*` environment
variables are absent. Services running with IAM roles no longer need to
export static credentials.
(PR [#4416](https://github.com/pipecat-ai/pipecat/pull/4416))
- Added `keyterms` support to ElevenLabs STT services so Scribe V2 callers can
bias transcription for both file-based and realtime transcription.
(PR [#4426](https://github.com/pipecat-ai/pipecat/pull/4426))
- Added `watchdog_min_timeout` parameter to `DeepgramFluxSTT` and
`DeepgramFluxSageMakerSTT` (default `0.5` seconds) to control the minimum
silence duration before the watchdog sends a silence packet to prevent
dangling turns. The actual threshold is `max(chunk_duration * 2,
watchdog_min_timeout)`, so it also adapts automatically to the audio chunk
size in use.
(PR [#4430](https://github.com/pipecat-ai/pipecat/pull/4430))
- Added `cancel_on_interruption=False` support for `GeminiLiveLLMService` on
models that support Gemini's NON_BLOCKING tool mechanism (currently Gemini
2.x); the conversation now continues while the tool runs. On models that
don't yet support NON_BLOCKING (Gemini 3.x), the service surfaces a one-time
warning explaining the limitation. (Note: an intermittent 1008 error can
occasionally fire on Gemini 2.5 during long-running tool calls; we
auto-reconnect.)
(PR [#4448](https://github.com/pipecat-ai/pipecat/pull/4448))
- Added `NvidiaSageMakerWebsocketSTTService` for streaming speech recognition
using NVIDIA Nemotron ASR via an AWS SageMaker bidirectional-stream endpoint.
Produces `InterimTranscriptionFrame` and `TranscriptionFrame` frames, is
VAD-aware, and automatically reconnects on error.
(PR [#4464](https://github.com/pipecat-ai/pipecat/pull/4464))
- Added NVIDIA Magpie TTS services via AWS SageMaker:
`NvidiaSageMakerHTTPTTSService` (single HTTP invocation, streams raw PCM
back) and `NvidiaSageMakerWebsocketTTSService` (persistent HTTP/2 bidi-stream
with full interruption support via `InterruptibleTTSService`).
(PR [#4464](https://github.com/pipecat-ai/pipecat/pull/4464))
- Added support for `reasoning` configuration on `OpenAIRealtimeLLMService`,
for use with reasoning-capable Realtime models such as `gpt-realtime-2`.
(PR [#4470](https://github.com/pipecat-ai/pipecat/pull/4470))
- Inworld TTS updates:
- Added `delivery_mode` setting (`STABLE`/`BALANCED`/`CREATIVE`) to
`InworldTTSService` and `InworldHttpTTSService`, enabling the
stability-vs-creativity tradeoff in `inworld-tts-2`.
- Added language support to `InworldTTSService` and
`InworldHttpTTSService`. The `language` setting is now forwarded to the API,
and a new `language_to_inworld_language()` helper normalizes Pipecat
`Language` enums to Inworld's BCP-47 locale tags.
(PR [#4473](https://github.com/pipecat-ai/pipecat/pull/4473))
### Changed
- Updated the default `SonioxTTSService` model from `tts-rt-v1-preview` to the
generally available `tts-rt-v1`.
(PR [#4386](https://github.com/pipecat-ai/pipecat/pull/4386))
- Default `cartesia_version` for `CartesiaTTSService` bumped from `2025-04-16`
to `2026-03-01`, matching `CartesiaHttpTTSService` and unlocking the
`use_normalized_timestamps` and `max_buffer_delay_ms` fields.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- ⚠️ `CartesiaTTSService` now sends `use_normalized_timestamps: true` instead
of the deprecated `use_original_timestamps` field. Word timestamps now
reflect what was actually spoken (post text-normalization and
pronunciation-dictionary substitution), matching the convention Pipecat uses
for ElevenLabs. This is a behavior change for `sonic-3` users, who were
previously receiving timestamps tied to the input transcript.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- Broadened `tool_resources` to `app_resources` for easy access not just in
tool handlers but in other places like custom `FrameProcessor`s. Three
changes: a rename (`tool_resources``app_resources`), a new `app_resources`
property on `PipelineTask`, and a new `pipeline_task` property on
`FrameProcessor`. Tool handlers now read `params.app_resources`; custom
processors read `self.pipeline_task.app_resources`. The previous
`tool_resources` aliases (on `PipelineTask`, `FunctionCallParams`, and
`FrameProcessorSetup`) keep working but are deprecated as of 1.2.0 and emit
`DeprecationWarning`s.
(PR [#4395](https://github.com/pipecat-ai/pipecat/pull/4395))
- Lowered the per-message log in
`SmallWebRTCInputTransport._handle_app_message` from `debug` to `trace`. App
messages can be high-frequency and were noisy at debug level; set the loguru
level to `TRACE` to see them again.
(PR [#4397](https://github.com/pipecat-ai/pipecat/pull/4397))
- Changed the default model for `GrokRealtimeLLMService` to
`grok-voice-think-fast-1.0`, xAI's recommended Voice Agent model. The
previous default of `grok-voice-fast-1.0` has been deprecated by xAI and is
being removed.
(PR [#4401](https://github.com/pipecat-ai/pipecat/pull/4401))
- Changed the default Inworld TTS model from `inworld-tts-1.5-max` to
`inworld-tts-2` (Realtime TTS-2) across `InworldHttpTTSService`,
`InworldTTSService`, and the `InworldRealtimeLLMService` cascade. Existing
users can pin the prior model explicitly via the `model`/`tts_model`
argument; both `inworld-tts-1.5-max` and `inworld-tts-1.5-mini` remain valid
model IDs.
(PR [#4422](https://github.com/pipecat-ai/pipecat/pull/4422))
- Changed the default model for `GrokLLMService` from `grok-3` to
`grok-4.20-non-reasoning`. xAI is retiring `grok-3` on May 15, 2026.
(PR [#4429](https://github.com/pipecat-ai/pipecat/pull/4429))
- `DeepgramFluxSTT` watchdog silence threshold is now dynamic:
`max(chunk_duration * 2, watchdog_min_timeout)` instead of a fixed 500 ms.
This prevents false silence injections when large audio chunks are sent at
lower frequency.
(PR [#4430](https://github.com/pipecat-ai/pipecat/pull/4430))
- `ElevenLabsTTSService` now sends `close_context` to the server as soon as the
turn is complete (on `on_turn_context_completed`) rather than waiting until
all audio has finished playing back. The `isFinal` message from ElevenLabs is
now used to signal `TTSStoppedFrame` and clean up the audio context,
improving turn transition timing.
(PR [#4433](https://github.com/pipecat-ai/pipecat/pull/4433))
- Updated `InworldHttpTTSService` and `InworldTTSService` to use PCM audio
encoding by default, which returns audio bytes without headers.
(PR [#4446](https://github.com/pipecat-ai/pipecat/pull/4446))
- Moved `create_task`, `cancel_task`, the `task_manager` property, and
`setup(task_manager)` up from `FrameProcessor` to `BaseObject`. Custom
`BaseObject` subclasses (turn strategies, controllers, etc.) now inherit
these methods directly instead of reimplementing the task manager wiring.
Owners propagate the task manager to their child `BaseObject`s via `await
child.setup(task_manager)`.
(PR [#4449](https://github.com/pipecat-ai/pipecat/pull/4449))
- Changed the default OpenAI Realtime input audio transcription model from
`gpt-4o-transcribe` to `gpt-realtime-whisper` for both
`OpenAIRealtimeSTTService` and `OpenAIRealtimeLLMService`. The new model does
not accept the `prompt` parameter; if a prompt is supplied alongside
`gpt-realtime-whisper`, it is dropped automatically and a warning is logged.
To keep using prompt hints, explicitly pin `model="gpt-4o-transcribe"` (or
`"gpt-4o-mini-transcribe"`).
(PR [#4450](https://github.com/pipecat-ai/pipecat/pull/4450))
- Updated the default model for `CartesiaTTSService` and
`CartesiaHttpTTSService` from `sonic-3` to `sonic-3.5`.
(PR [#4462](https://github.com/pipecat-ai/pipecat/pull/4462))
- Changed the default model for `OpenAIRealtimeLLMService` from
`gpt-realtime-1.5` to `gpt-realtime-2`.
(PR [#4472](https://github.com/pipecat-ai/pipecat/pull/4472))
### Deprecated
- Deprecated `LLMUserAggregatorParams.filter_incomplete_user_turns`. Use
`user_turn_strategies=FilterIncompleteUserTurnStrategies()` (or add
`LLMTurnCompletionUserTurnStopStrategy` to a custom
`user_turn_strategies.stop`) instead. Setting the legacy flag still works for
one release: the aggregator emits a `DeprecationWarning` and rewires the
strategies as if you had passed `FilterIncompleteUserTurnStrategies`
directly.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Deprecated `ResampyResampler` in favor of `SOXRAudioResampler` (or the
`create_file_resampler()` / `create_stream_resampler()` factories).
Instantiating `ResampyResampler` now emits a `DeprecationWarning`. The class
will be removed in Pipecat 2.0 along with the default `resampy` and `numba`
dependencies.
(PR [#4428](https://github.com/pipecat-ai/pipecat/pull/4428))
### Fixed
- Fixed `CartesiaTTSService` surfacing `flush_done` messages from Cartesia as
`ErrorFrame`s. The latest API emits a `flush_done` per transcript when
server-side buffering is disabled; Pipecat now consumes them silently since
each turn already has its own `context_id`.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- Fixed Cartesia tag helpers (`SPELL`, `EMOTION_TAG`, `PAUSE_TAG`,
`VOLUME_TAG`, `SPEED_TAG`) raising `TypeError` when called on an instance
(e.g. `tts.SPELL("hi")`). They're now `@staticmethod` and callable from both
the class and an instance.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- Fixed `CartesiaHttpTTSService` pushing two `ErrorFrame`s on a non-200
response — one with the API's error text and a second, less informative
"Unknown error" frame from the outer exception handler. It now pushes a
single frame that includes the HTTP status code and returns cleanly.
(PR [#4390](https://github.com/pipecat-ai/pipecat/pull/4390))
- Fixed an issue where `LocalSmartTurnAnalyzerV3` was imported unconditionally
for user turn stop strategies. It is now only imported when
`default_user_turn_stop_strategies()` is called. This improves startup time
and removes the `transformers` "PyTorch/TensorFlow/Flax not found" warning
when the default stop strategies are not used.
(PR [#4393](https://github.com/pipecat-ai/pipecat/pull/4393))
- Fixed `GrokRealtimeLLMService` ignoring the configured model. The model was
stored in `Settings` but never sent to xAI, so every session silently fell
back to xAI's server-side default. The model is now passed via the `?model=`
query parameter on the WebSocket URL as xAI's Voice Agent API requires.
(PR [#4401](https://github.com/pipecat-ai/pipecat/pull/4401))
- Fixed `on_user_turn_stopped` firing prematurely when
`filter_incomplete_user_turns` was enabled. The event now fires only after
the LLM confirms the user turn is complete (`✓`); previously the smart-turn
detector's tentative stop was bubbling up before the LLM had a chance to veto
it, causing observers, transcript appenders and UI indicators to receive an
early — and sometimes duplicated — signal.
(PR [#4405](https://github.com/pipecat-ai/pipecat/pull/4405))
- Fixed `TTSSpeakFrame(append_to_context=True)` greetings sometimes splitting
across two assistant messages in the LLM context and not surfacing in
`on_assistant_turn_stopped`. The `LLMAssistantPushAggregationFrame` emitted
at the end of a TTS context now carries a PTS just past the last word so it
can't overtake clock-queued `TTSTextFrame`s in the transport's output, and
`LLMAssistantAggregator` now triggers
`on_assistant_turn_started`/`on_assistant_turn_stopped` when it receives the
frame outside an LLM response cycle (restoring v0.0.104 behavior for greeting
transcripts).
(PR [#4414](https://github.com/pipecat-ai/pipecat/pull/4414))
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` producing merged
words (e.g. `bookLook`) when using Flash models. Flash often splits sentences
mid-stream into alignment chunks that begin with a real inter-word space, but
the previous fix unconditionally stripped that space from every chunk.
Leading spaces are now stripped only on the first alignment chunk of an
utterance, so subsequent chunks correctly flush partial words across
boundaries.
(PR [#4415](https://github.com/pipecat-ai/pipecat/pull/4415))
- Fixed AWS Polly TTS, Bedrock LLM, and the Bedrock AgentCore processor
erroring out when only one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
was set in the environment. The half-populated kwargs are no longer forwarded
to aioboto3; partial env-var configurations now fall through to the boto3
credential chain like fully-unset configurations do.
(PR [#4416](https://github.com/pipecat-ai/pipecat/pull/4416))
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` writing
romanized/normalized text to the LLM context. With non-Latin input (e.g.,
Chinese), the assistant transcript was getting populated with pinyin (`Ni Hao
!` instead of `你好!`), which then degraded subsequent LLM turns. The services
now consume `alignment` by default and only switch to `normalizedAlignment` /
`normalized_alignment` when `pronunciation_dictionary_locators` is configured
(where `alignment` has overlapping restarts that produce duplicated/garbled
words, per #4316). Both fields are read with preferred-with-fallback
semantics since each is nullable per the API schema.
(PR [#4424](https://github.com/pipecat-ai/pipecat/pull/4424))
- Fixed a deadlock in `TTSService` that could permanently stall pipeline
processing when all three conditions occurred together:
`pause_frame_processing=True`, an interruption arrived before any TTS audio
was played, and an `UninterruptibleFrame` (e.g. `TTSUpdateSettingsFrame`,
`FunctionCallResultFrame`) was in the processing queue at that moment. The
process task would block on `__process_event.wait()` indefinitely because
`BotStoppedSpeakingFrame` never arrives (no audio was played) and the
interruption handler did not resume processing. Affects services using
`pause_frame_processing=True` such as ElevenLabs, Rime, AsyncAI, Gradium, and
ResembleAI.
(PR [#4431](https://github.com/pipecat-ai/pipecat/pull/4431))
- Fixed interruptions being delayed when a slow non-uninterruptible frame was
processing and an uninterruptible frame was waiting in the queue. The bot
would stall until the slow frame finished instead of cancelling it
immediately on interruption.
(PR [#4434](https://github.com/pipecat-ai/pipecat/pull/4434))
- Fixed `TTSService` dropping uninterruptible frames (e.g.
`FunctionCallResultFrame`) from its internal serialization queue when an
interruption occurs. Previously, the queue was recreated on every
interruption, silently discarding any queued frames. The queue is now reset
instead of recreated, preserving uninterruptible frames so they are always
delivered downstream.
(PR [#4435](https://github.com/pipecat-ai/pipecat/pull/4435))
- Fixed a race condition in the Daily transport that caused `AttributeError:
'NoneType' object has no attribute 'send_app_message'` when tearing down a
pipeline. Both `DailyInputTransport` and `DailyOutputTransport` share the
same `DailyTransportClient` and both call `cleanup()`, which was releasing
the underlying `CallClient` on the first call — leaving the second caller
with a `None` client.
(PR [#4440](https://github.com/pipecat-ai/pipecat/pull/4440))
- Restored `cancel_on_interruption=False` support for `AWSNovaSonicLLMService`
and `OpenAIRealtimeLLMService`. These services previously honored the flag by
simply not cancelling in-flight function calls on interruption; the
introduction of the new async-tool mechanism (which threads
started/intermediate/final messages through the LLM context) broke that path
because the realtime services didn't know how to interpret those messages.
Note that new-style streamed intermediate results
(`FunctionCallResultProperties(is_final=False)`) are not supported on these
realtime services. Similar fixes for other impacted realtime services are
forthcoming.
(PR [#4441](https://github.com/pipecat-ai/pipecat/pull/4441))
- Fixed two misspelled Gemini TTS voice names in
`GeminiTTSService.AVAILABLE_VOICES`.
(PR [#4443](https://github.com/pipecat-ai/pipecat/pull/4443))
- Extended the `cancel_on_interruption=False` regression fix to
`GrokRealtimeLLMService`, `AzureRealtimeLLMService`, and
`UltravoxRealtimeLLMService`. Grok and Azure use the same approach as in
#4441 (each service detects async-tool messages in the LLM context and routes
the final result to its formal tool-result channel; Azure inherits
transitively from `OpenAIRealtimeLLMService`). Ultravox needed a different
approach because its API freezes the conversation between
`client_tool_invocation` and the matching `client_tool_result` — for
async-registered functions it now ships a placeholder `client_tool_result`
immediately when the function is invoked (to unfreeze the conversation), then
injects the real result as user-side text once the tool finishes. Streamed
intermediate results (`FunctionCallResultProperties(is_final=False)`) are
still not supported on any of these realtime services. `GeminiLiveLLMService`
and `InworldRealtimeLLMService` are excluded for now: Gemini Live's
async-tool path needs deeper investigation, and Inworld tool calling needs to
be sorted out first.
(PR [#4447](https://github.com/pipecat-ai/pipecat/pull/4447))
- Fixed `OpenAIRealtimeLLMService` handling of multi-output-item responses
(observed with `gpt-realtime-2`). A single response can now contain more than
one audio item, and the first item's `audio.done` may arrive after the second
item's deltas have started. Deltas still arrive strictly in playback order,
so we continue to forward them as received (matching OpenAI's reference
implementation). The fix removes spurious warnings, ensures truncation always
targets the latest audio item, and emits a single bracketing
`TTSStartedFrame`/`TTSStoppedFrame` pair per assistant turn (the Stopped is
now pushed on `response.done`).
(PR [#4465](https://github.com/pipecat-ai/pipecat/pull/4465))
- Fixed missing `output` attribute on LLM OpenTelemetry spans when the LLM call
is interrupted mid-stream.
(PR [#4467](https://github.com/pipecat-ai/pipecat/pull/4467))
- Fixed incorrect `metrics.ttfb` on STT OpenTelemetry spans, and parented them
to the current turn span.
(PR [#4467](https://github.com/pipecat-ai/pipecat/pull/4467))
- Fixed incorrect `metrics.ttfb` on TTS OpenTelemetry spans for streaming
services.
(PR [#4467](https://github.com/pipecat-ai/pipecat/pull/4467))
- Extended the `cancel_on_interruption=False` regression fix to
`InworldRealtimeLLMService`. Uses the same approach as in #4441 (the service
detects async-tool messages in the LLM context and routes the final result to
its formal tool-result channel). Note: as of this writing, Inworld Realtime
doesn't appear to handle the resulting delayed tool result reliably — the
routing is best-effort and the service surfaces a one-time warning when
async-tool messages are seen. Streamed intermediate results
(`FunctionCallResultProperties(is_final=False)`) are still not supported on
this realtime service. (Inworld was excluded from #4447 pending resolution of
an unrelated tool-calling issue, which turned out to be an account-level
matter.)
(PR [#4474](https://github.com/pipecat-ai/pipecat/pull/4474))
- Fixed Cartesia TTS Korean word timestamps to use normal spacing rules,
preserving word boundaries and per-word timestamp alignment during downstream
aggregation.
(PR [#4475](https://github.com/pipecat-ai/pipecat/pull/4475))
- Fixed Cartesia TTS Chinese and Japanese timestamp grouping to preserve
provider text spacing, avoiding artificial spaces when timestamp groups are
reassembled downstream.
(PR [#4475](https://github.com/pipecat-ai/pipecat/pull/4475))
- Fixed `SonioxSTTService` final transcription frames missing detected language
metadata when Soniox returns token-level language annotations.
(PR [#4482](https://github.com/pipecat-ai/pipecat/pull/4482))
- Fixed Soniox final transcription language detection to use the most common
recognized token language, avoiding mislabeling an utterance when the last
token is tagged with a different language.
(PR [#4495](https://github.com/pipecat-ai/pipecat/pull/4495))
- Fixed dropped audio in streaming TTS services whose wire protocol doesn't
echo `context_id` back on incoming audio (Sarvam, Smallest, Soniox, Inworld,
and others). Previously, audio that arrived between contexts or at the very
start of a turn was tagged with `context_id=None` and silently dropped with
an "unable to append audio to context: no context ID provided" debug log.
`TTSService.get_active_audio_context_id()` now falls back to the
synthesis-side `_turn_context_id` when the playback cursor isn't set yet.
(PR [#4497](https://github.com/pipecat-ai/pipecat/pull/4497))
### Security
- Fixed a path traversal issue in the development runner's
`/files/{filename:path}` download endpoint. Previously, when the runner was
started with `--folder`, a request like `/files/..%2F..%2Fetc%2Fpasswd` could
escape the configured folder because `%2F`-encoded separators bypassed
Starlette's path normalisation. The endpoint now resolves the joined path and
rejects any filename that escapes the allowed base with a 403, and also
returns 404 (instead of an implicit `null` 200) when `--folder` is unset.
(PR [#4417](https://github.com/pipecat-ai/pipecat/pull/4417))
## [1.1.0] - 2026-04-27
### Added

View File

@@ -1 +0,0 @@
- Added a `session_id` field to `RunnerArguments` so bots can log or trace a per-session identifier in local development the same way they can in Pipecat Cloud. The development runner now mints a UUID at every construction site, and paths that already returned a `sessionId` to the caller (Daily `/start`, dial-in webhook) share that same UUID with the runner args instead of generating two. The SmallWebRTC `/api/offer` endpoint also accepts an optional `session_id` query parameter so the `/sessions/{session_id}/...` proxy can thread it through.

View File

@@ -1 +0,0 @@
- Updated the default `SonioxTTSService` model from `tts-rt-v1-preview` to the generally available `tts-rt-v1`.

View File

@@ -1 +0,0 @@
- Added a `max_buffer_delay_ms` constructor argument to `CartesiaTTSService` for controlling Cartesia's server-side text buffering. When unset, Pipecat picks a sensible default based on `text_aggregation_mode`: `0` in `SENTENCE` mode (custom buffering — avoids stacking client-side aggregation on top of Cartesia's default 3000ms server buffer) and unset in `TOKEN` mode (Cartesia's managed buffering applies). Pass an explicit value (05000ms) to override.

View File

@@ -1 +0,0 @@
- Default `cartesia_version` for `CartesiaTTSService` bumped from `2025-04-16` to `2026-03-01`, matching `CartesiaHttpTTSService` and unlocking the `use_normalized_timestamps` and `max_buffer_delay_ms` fields.

View File

@@ -1 +0,0 @@
- ⚠️ `CartesiaTTSService` now sends `use_normalized_timestamps: true` instead of the deprecated `use_original_timestamps` field. Word timestamps now reflect what was actually spoken (post text-normalization and pronunciation-dictionary substitution), matching the convention Pipecat uses for ElevenLabs. This is a behavior change for `sonic-3` users, who were previously receiving timestamps tied to the input transcript.

View File

@@ -1 +0,0 @@
- Fixed `CartesiaHttpTTSService` pushing two `ErrorFrame`s on a non-200 response — one with the API's error text and a second, less informative "Unknown error" frame from the outer exception handler. It now pushes a single frame that includes the HTTP status code and returns cleanly.

View File

@@ -1 +0,0 @@
- Fixed Cartesia tag helpers (`SPELL`, `EMOTION_TAG`, `PAUSE_TAG`, `VOLUME_TAG`, `SPEED_TAG`) raising `TypeError` when called on an instance (e.g. `tts.SPELL("hi")`). They're now `@staticmethod` and callable from both the class and an instance.

View File

@@ -1 +0,0 @@
- Fixed `CartesiaTTSService` surfacing `flush_done` messages from Cartesia as `ErrorFrame`s. The latest API emits a `flush_done` per transcript when server-side buffering is disabled; Pipecat now consumes them silently since each turn already has its own `context_id`.

View File

@@ -1 +0,0 @@
- Fixed an issue where `LocalSmartTurnAnalyzerV3` was imported unconditionally for user turn stop strategies. It is now only imported when `default_user_turn_stop_strategies()` is called. This improves startup time and removes the `transformers` "PyTorch/TensorFlow/Flax not found" warning when the default stop strategies are not used.

View File

@@ -1 +0,0 @@
- Broadened `tool_resources` to `app_resources` for easy access not just in tool handlers but in other places like custom `FrameProcessor`s. Three changes: a rename (`tool_resources``app_resources`), a new `app_resources` property on `PipelineTask`, and a new `pipeline_task` property on `FrameProcessor`. Tool handlers now read `params.app_resources`; custom processors read `self.pipeline_task.app_resources`. The previous `tool_resources` aliases (on `PipelineTask`, `FunctionCallParams`, and `FrameProcessorSetup`) keep working but are deprecated as of 1.2.0 and emit `DeprecationWarning`s.

View File

@@ -1 +0,0 @@
- Lowered the per-message log in `SmallWebRTCInputTransport._handle_app_message` from `debug` to `trace`. App messages can be high-frequency and were noisy at debug level; set the loguru level to `TRACE` to see them again.

View File

@@ -1 +0,0 @@
- Added a `mip_opt_out` constructor argument to `DeepgramTTSService` and `DeepgramHttpTTSService` so callers can opt out of the Deepgram Model Improvement Program. When set, the value is forwarded to Deepgram as a query parameter on the speak request. Defaults to `None`, which preserves the existing behavior. See https://dpgr.am/deepgram-mip for pricing implications before enabling.

View File

@@ -1 +0,0 @@
- Changed the default model for `GrokRealtimeLLMService` to `grok-voice-think-fast-1.0`, xAI's recommended Voice Agent model. The previous default of `grok-voice-fast-1.0` has been deprecated by xAI and is being removed.

View File

@@ -1 +0,0 @@
- Fixed `GrokRealtimeLLMService` ignoring the configured model. The model was stored in `Settings` but never sent to xAI, so every session silently fell back to xAI's server-side default. The model is now passed via the `?model=` query parameter on the WebSocket URL as xAI's Voice Agent API requires.

View File

@@ -1 +0,0 @@
- Added an opt-in `add_tool_change_messages` flag to the LLM aggregators (set via `LLMContextAggregatorPair(..., add_tool_change_messages=True)`) that appends a developer-role message to the context whenever `LLMSetToolsFrame` changes the set of advertised standard tools. Helps the LLM stay coherent across mid-conversation tool changes, mitigating several flavors of tool-call-related hallucination: calling tools that have been removed, avoiding tools that have been re-added, and hallucinating output (made-up answers or tool-call-shaped non-tool-calls) when tools are unavailable.

View File

@@ -1 +0,0 @@
- Added `LLMTurnCompletionUserTurnStopStrategy` in `pipecat.turns.user_stop`. When installed, the strategy gates `on_user_turn_stopped` on a `UserTurnInferenceCompletedFrame` (a new fieldless system frame emitted by any component that can judge turn completeness — e.g. the `UserTurnCompletionLLMServiceMixin` on `✓`). A `finalization_timeout` provides a safety net if no completion frame ever arrives.

View File

@@ -1 +0,0 @@
- Added `deferred(strategy)` and `DeferredUserTurnStopStrategy` in `pipecat.turns.user_stop`. Wraps a stop strategy so it fires only the inference-triggered event and suppresses `on_user_turn_stopped`, leaving finalization to another strategy in the chain such as `LLMTurnCompletionUserTurnStopStrategy`.

View File

@@ -1 +0,0 @@
- Added `FilterIncompleteUserTurnStrategies` in `pipecat.turns.user_turn_strategies` — a `UserTurnStrategies` specialization that wraps the detector chain with `deferred(...)` and appends `LLMTurnCompletionUserTurnStopStrategy` as the finalizer. Common case: `user_turn_strategies=FilterIncompleteUserTurnStrategies()`. Pass `config=UserTurnCompletionConfig(...)` to customize timeouts and prompts.

View File

@@ -1 +0,0 @@
- Added `ExternalUserTurnCompletionStopStrategy` in `pipecat.turns.user_stop` — a generic stop strategy that finalizes the user turn whenever a `UserTurnInferenceCompletedFrame` arrives, regardless of which component produced it. `LLMTurnCompletionUserTurnStopStrategy` now extends this base; future producers (Flux, custom end-of-turn classifiers, etc.) can use the base directly or subclass it to add producer-specific setup.

View File

@@ -1 +0,0 @@
- Added `on_user_turn_inference_triggered`, a new event on the user turn controller, processor, aggregator and stop strategies that fires when a strategy has enough signal to start LLM inference. By default it fires together with `on_user_turn_stopped`; a gating strategy can fire only the inference-triggered event and defer finalization to a peer.

View File

@@ -1 +0,0 @@
- Deprecated `LLMUserAggregatorParams.filter_incomplete_user_turns`. Use `user_turn_strategies=FilterIncompleteUserTurnStrategies()` (or add `LLMTurnCompletionUserTurnStopStrategy` to a custom `user_turn_strategies.stop`) instead. Setting the legacy flag still works for one release: the aggregator emits a `DeprecationWarning` and rewires the strategies as if you had passed `FilterIncompleteUserTurnStrategies` directly.

View File

@@ -1 +0,0 @@
- Fixed `on_user_turn_stopped` firing prematurely when `filter_incomplete_user_turns` was enabled. The event now fires only after the LLM confirms the user turn is complete (`✓`); previously the smart-turn detector's tentative stop was bubbling up before the LLM had a chance to veto it, causing observers, transcript appenders and UI indicators to receive an early — and sometimes duplicated — signal.

View File

@@ -1,6 +0,0 @@
- Added first-class RTVI support for the UI Agent Protocol:
- Adds `ui-event`, `ui-snapshot`, and `ui-cancel-task` client-to-server messages, plus `ui-command` and `ui-task` server-to-client messages, with paired `*Data` / `*Message` pydantic models.
- Adds built-in command payload models for `Toast`, `Navigate`, `ScrollTo`, `Highlight`, `Focus`, `Click`, `SetInputValue`, and `SelectText`; matching default handlers live in `@pipecat-ai/client-react`.
- Adds `RTVIProcessor.on_ui_message` for inbound `ui-event`, `ui-snapshot`, and `ui-cancel-task` messages.
- Adds five UI pipeline frames, mirroring the `client-message` frame-and-event pattern: downstream code pushes `RTVIUICommandFrame` / `RTVIUITaskFrame` for the observer to wrap into outbound `UICommandMessage` / `UITaskMessage` envelopes, while the processor pushes inbound `RTVIUIEventFrame`, `RTVIUISnapshotFrame`, and `RTVIUICancelTaskFrame` alongside `on_ui_message`.
- Bumps the RTVI `PROTOCOL_VERSION` from `1.2.0` to `1.3.0`.

View File

@@ -1 +0,0 @@
- Fixed `TTSSpeakFrame(append_to_context=True)` greetings sometimes splitting across two assistant messages in the LLM context and not surfacing in `on_assistant_turn_stopped`. The `LLMAssistantPushAggregationFrame` emitted at the end of a TTS context now carries a PTS just past the last word so it can't overtake clock-queued `TTSTextFrame`s in the transport's output, and `LLMAssistantAggregator` now triggers `on_assistant_turn_started`/`on_assistant_turn_stopped` when it receives the frame outside an LLM response cycle (restoring v0.0.104 behavior for greeting transcripts).

View File

@@ -1 +0,0 @@
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` producing merged words (e.g. `bookLook`) when using Flash models. Flash often splits sentences mid-stream into alignment chunks that begin with a real inter-word space, but the previous fix unconditionally stripped that space from every chunk. Leading spaces are now stripped only on the first alignment chunk of an utterance, so subsequent chunks correctly flush partial words across boundaries.

View File

@@ -1 +0,0 @@
- AWS Transcribe STT, Polly TTS, Bedrock LLM, and the Bedrock AgentCore processor now resolve credentials via the standard boto3 provider chain (EC2 instance profiles, EKS pod roles / IRSA, ECS task roles, SSO, `~/.aws/credentials`) when explicit credentials and `AWS_*` environment variables are absent. Services running with IAM roles no longer need to export static credentials.

View File

@@ -1 +0,0 @@
- Fixed AWS Polly TTS, Bedrock LLM, and the Bedrock AgentCore processor erroring out when only one of `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` was set in the environment. The half-populated kwargs are no longer forwarded to aioboto3; partial env-var configurations now fall through to the boto3 credential chain like fully-unset configurations do.

View File

@@ -1 +0,0 @@
- Fixed a path traversal issue in the development runner's `/files/{filename:path}` download endpoint. Previously, when the runner was started with `--folder`, a request like `/files/..%2F..%2Fetc%2Fpasswd` could escape the configured folder because `%2F`-encoded separators bypassed Starlette's path normalisation. The endpoint now resolves the joined path and rejects any filename that escapes the allowed base with a 403, and also returns 404 (instead of an implicit `null` 200) when `--folder` is unset.

View File

@@ -1 +0,0 @@
- Changed the default Inworld TTS model from `inworld-tts-1.5-max` to `inworld-tts-2` (Realtime TTS-2) across `InworldHttpTTSService`, `InworldTTSService`, and the `InworldRealtimeLLMService` cascade. Existing users can pin the prior model explicitly via the `model`/`tts_model` argument; both `inworld-tts-1.5-max` and `inworld-tts-1.5-mini` remain valid model IDs.

View File

@@ -1 +0,0 @@
- Fixed `ElevenLabsTTSService` and `ElevenLabsHttpTTSService` writing romanized/normalized text to the LLM context. With non-Latin input (e.g., Chinese), the assistant transcript was getting populated with pinyin (`Ni Hao !` instead of `你好!`), which then degraded subsequent LLM turns. The services now consume `alignment` by default and only switch to `normalizedAlignment` / `normalized_alignment` when `pronunciation_dictionary_locators` is configured (where `alignment` has overlapping restarts that produce duplicated/garbled words, per #4316). Both fields are read with preferred-with-fallback semantics since each is nullable per the API schema.

View File

@@ -1 +0,0 @@
- Added `keyterms` support to ElevenLabs STT services so Scribe V2 callers can bias transcription for both file-based and realtime transcription.

View File

@@ -1 +0,0 @@
- Deprecated `ResampyResampler` in favor of `SOXRAudioResampler` (or the `create_file_resampler()` / `create_stream_resampler()` factories). Instantiating `ResampyResampler` now emits a `DeprecationWarning`. The class will be removed in Pipecat 2.0 along with the default `resampy` and `numba` dependencies.

View File

@@ -1 +0,0 @@
- Changed the default model for `GrokLLMService` from `grok-3` to `grok-4.20-non-reasoning`. xAI is retiring `grok-3` on May 15, 2026.

View File

@@ -1 +0,0 @@
- Added `watchdog_min_timeout` parameter to `DeepgramFluxSTT` and `DeepgramFluxSageMakerSTT` (default `0.5` seconds) to control the minimum silence duration before the watchdog sends a silence packet to prevent dangling turns. The actual threshold is `max(chunk_duration * 2, watchdog_min_timeout)`, so it also adapts automatically to the audio chunk size in use.

View File

@@ -1 +0,0 @@
- `DeepgramFluxSTT` watchdog silence threshold is now dynamic: `max(chunk_duration * 2, watchdog_min_timeout)` instead of a fixed 500 ms. This prevents false silence injections when large audio chunks are sent at lower frequency.

View File

@@ -1 +0,0 @@
- Fixed a deadlock in `TTSService` that could permanently stall pipeline processing when all three conditions occurred together: `pause_frame_processing=True`, an interruption arrived before any TTS audio was played, and an `UninterruptibleFrame` (e.g. `TTSUpdateSettingsFrame`, `FunctionCallResultFrame`) was in the processing queue at that moment. The process task would block on `__process_event.wait()` indefinitely because `BotStoppedSpeakingFrame` never arrives (no audio was played) and the interruption handler did not resume processing. Affects services using `pause_frame_processing=True` such as ElevenLabs, Rime, AsyncAI, Gradium, and ResembleAI.

View File

@@ -1 +0,0 @@
- `ElevenLabsTTSService` now sends `close_context` to the server as soon as the turn is complete (on `on_turn_context_completed`) rather than waiting until all audio has finished playing back. The `isFinal` message from ElevenLabs is now used to signal `TTSStoppedFrame` and clean up the audio context, improving turn transition timing.

View File

@@ -1 +0,0 @@
- Fixed interruptions being delayed when a slow non-uninterruptible frame was processing and an uninterruptible frame was waiting in the queue. The bot would stall until the slow frame finished instead of cancelling it immediately on interruption.

View File

@@ -1 +0,0 @@
- Fixed `TTSService` dropping uninterruptible frames (e.g. `FunctionCallResultFrame`) from its internal serialization queue when an interruption occurs. Previously, the queue was recreated on every interruption, silently discarding any queued frames. The queue is now reset instead of recreated, preserving uninterruptible frames so they are always delivered downstream.

View File

@@ -1 +0,0 @@
- Fixed a race condition in the Daily transport that caused `AttributeError: 'NoneType' object has no attribute 'send_app_message'` when tearing down a pipeline. Both `DailyInputTransport` and `DailyOutputTransport` share the same `DailyTransportClient` and both call `cleanup()`, which was releasing the underlying `CallClient` on the first call — leaving the second caller with a `None` client.

View File

@@ -1 +0,0 @@
- Restored `cancel_on_interruption=False` support for `AWSNovaSonicLLMService` and `OpenAIRealtimeLLMService`. These services previously honored the flag by simply not cancelling in-flight function calls on interruption; the introduction of the new async-tool mechanism (which threads started/intermediate/final messages through the LLM context) broke that path because the realtime services didn't know how to interpret those messages. Note that new-style streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are not supported on these realtime services. Similar fixes for other impacted realtime services are forthcoming.

View File

@@ -1 +0,0 @@
- Fixed two misspelled Gemini TTS voice names in `GeminiTTSService.AVAILABLE_VOICES`.

View File

@@ -1 +0,0 @@
- Updated `InworldHttpTTSService` and `InworldTTSService` to use PCM audio encoding by default, which returns audio bytes without headers.

View File

@@ -1 +0,0 @@
- Extended the `cancel_on_interruption=False` regression fix to `GrokRealtimeLLMService`, `AzureRealtimeLLMService`, and `UltravoxRealtimeLLMService`. Grok and Azure use the same approach as in #4441 (each service detects async-tool messages in the LLM context and routes the final result to its formal tool-result channel; Azure inherits transitively from `OpenAIRealtimeLLMService`). Ultravox needed a different approach because its API freezes the conversation between `client_tool_invocation` and the matching `client_tool_result` — for async-registered functions it now ships a placeholder `client_tool_result` immediately when the function is invoked (to unfreeze the conversation), then injects the real result as user-side text once the tool finishes. Streamed intermediate results (`FunctionCallResultProperties(is_final=False)`) are still not supported on any of these realtime services. `GeminiLiveLLMService` and `InworldRealtimeLLMService` are excluded for now: Gemini Live's async-tool path needs deeper investigation, and Inworld appears to have a pre-existing problem with even simple tool calling on its Realtime API.

View File

@@ -1 +0,0 @@
- Added `cancel_on_interruption=False` support for `GeminiLiveLLMService` on models that support Gemini's NON_BLOCKING tool mechanism (currently Gemini 2.x); the conversation now continues while the tool runs. On models that don't yet support NON_BLOCKING (Gemini 3.x), the service surfaces a one-time warning explaining the limitation. (Note: an intermittent 1008 error can occasionally fire on Gemini 2.5 during long-running tool calls; we auto-reconnect.)

View File

@@ -1 +0,0 @@
- Moved `create_task`, `cancel_task`, the `task_manager` property, and `setup(task_manager)` up from `FrameProcessor` to `BaseObject`. Custom `BaseObject` subclasses (turn strategies, controllers, etc.) now inherit these methods directly instead of reimplementing the task manager wiring. Owners propagate the task manager to their child `BaseObject`s via `await child.setup(task_manager)`.

View File

@@ -1 +0,0 @@
- Changed the default OpenAI Realtime input audio transcription model from `gpt-4o-transcribe` to `gpt-realtime-whisper` for both `OpenAIRealtimeSTTService` and `OpenAIRealtimeLLMService`. The new model does not accept the `prompt` parameter; if a prompt is supplied alongside `gpt-realtime-whisper`, it is dropped automatically and a warning is logged. To keep using prompt hints, explicitly pin `model="gpt-4o-transcribe"` (or `"gpt-4o-mini-transcribe"`).

View File

@@ -1 +0,0 @@
- Updated the default model for `CartesiaTTSService` and `CartesiaHttpTTSService` from `sonic-3` to `sonic-3.5`.

View File

@@ -1 +0,0 @@
- Added NVIDIA Magpie TTS services via AWS SageMaker: `NvidiaSageMakerHTTPTTSService` (single HTTP invocation, streams raw PCM back) and `NvidiaSageMakerWebsocketTTSService` (persistent HTTP/2 bidi-stream with full interruption support via `InterruptibleTTSService`).

View File

@@ -1 +0,0 @@
- Added `NvidiaSageMakerWebsocketSTTService` for streaming speech recognition using NVIDIA Nemotron ASR via an AWS SageMaker bidirectional-stream endpoint. Produces `InterimTranscriptionFrame` and `TranscriptionFrame` frames, is VAD-aware, and automatically reconnects on error.

View File

@@ -1 +0,0 @@
- Fixed `OpenAIRealtimeLLMService` handling of multi-output-item responses (observed with `gpt-realtime-2`). A single response can now contain more than one audio item, and the first item's `audio.done` may arrive after the second item's deltas have started. Deltas still arrive strictly in playback order, so we continue to forward them as received (matching OpenAI's reference implementation). The fix removes spurious warnings, ensures truncation always targets the latest audio item, and emits a single bracketing `TTSStartedFrame`/`TTSStoppedFrame` pair per assistant turn (the Stopped is now pushed on `response.done`).

View File

@@ -1 +0,0 @@
- Added support for `reasoning` configuration on `OpenAIRealtimeLLMService`, for use with reasoning-capable Realtime models such as `gpt-realtime-2`.

View File

@@ -1 +0,0 @@
- Changed the default model for `OpenAIRealtimeLLMService` from `gpt-realtime-1.5` to `gpt-realtime-2`.

View File

@@ -0,0 +1 @@
- Changed the default WebSocket endpoints for `GradiumSTTService` and `GradiumTTSService` to the region-neutral `wss://api.gradium.ai/api/speech/asr` and `wss://api.gradium.ai/api/speech/tts`. Gradium now automatically routes traffic to the nearest endpoint. Override the url to pin to a specific region.

View File

@@ -71,8 +71,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
llm = QwenLLMService(
api_key=os.environ["QWEN_API_KEY"],
model="qwen2.5-72b-instruct",
settings=QwenLLMService.Settings(
model="qwen2.5-72b-instruct",
system_instruction="You are a helpful assistant in a voice conversation. Your responses will be spoken aloud, so avoid emojis, bullet points, or other formatting that can't be spoken. Respond to what the user said in a creative, helpful, and brief way.",
),
)

View File

@@ -28,10 +28,14 @@ Usage:
"""
import os
import random
from datetime import datetime
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import LLMRunFrame
from pipecat.observers.loggers.transcription_log_observer import (
TranscriptionLogObserver,
@@ -48,6 +52,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.inworld.realtime.llm import InworldRealtimeLLMService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
@@ -55,6 +60,43 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams
load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
temperature = (
random.randint(60, 85)
if params.arguments["format"] == "fahrenheit"
else random.randint(15, 30)
)
await params.result_callback(
{
"conditions": "nice",
"temperature": temperature,
"location": params.arguments["location"],
"format": params.arguments["format"],
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S"),
}
)
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"format": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Infer this from the users location.",
},
},
required=["location", "format"],
)
tools = ToolsSchema(standard_tools=[weather_function])
# --- Transport Configuration ---
# No local VAD needed — Inworld's server-side semantic VAD handles turn detection.
@@ -85,7 +127,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
# See: https://docs.inworld.ai/router/introduction
llm = InworldRealtimeLLMService(
api_key=os.environ["INWORLD_API_KEY"],
llm_model="xai/grok-4-1-fast-non-reasoning",
llm_model="openai/gpt-4.1-mini",
voice="Sarah",
settings=InworldRealtimeLLMService.Settings(
system_instruction="""You are a helpful and friendly AI assistant powered by Inworld.
@@ -97,9 +139,14 @@ Always be helpful and proactive in offering assistance.""",
),
)
# Create context with initial message
# Note: function calling requires a paid Inworld account and a
# function-calling-capable model
llm.register_function("get_current_weather", fetch_weather_from_api)
# Create context with initial message + tools
context = LLMContext(
[{"role": "developer", "content": "Say hello and introduce yourself!"}],
tools,
)
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(context)

View File

@@ -51,7 +51,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
stt = GradiumSTTService(
api_key=os.environ["GRADIUM_API_KEY"],
api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr",
settings=GradiumSTTService.Settings(
language=Language.EN,
delay_in_frames=8,

View File

@@ -0,0 +1,209 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""MOQ (Media over QUIC) transport example.
This example demonstrates using the MOQ transport for real-time voice
conversations over QUIC, connecting to a MOQ relay server. It uses the
unified runner pattern that works with Daily, WebRTC, and MOQ transports.
MOQ provides WebRTC-like latency without WebRTC constraints, using QUIC
for prioritization and partial reliability.
Requirements:
uv sync --extra moq --extra silero --extra deepgram --extra cartesia \
--extra openai --extra runner
# You also need a MOQ relay running locally. Clone moq-relay from
# https://github.com/kixelated/moq and then run scripts/moq-dev-setup.sh
# from this repo, pointing at the relay checkout. The script generates
# a self-signed cert, symlinks it into both repos, and prints the
# exact relay + bot run commands to copy.
#
# git clone https://github.com/kixelated/moq.git ../moq
# ./scripts/moq-dev-setup.sh ../moq
#
# Then in two terminals run the commands the script printed (relay
# binds QUIC on UDP [::]:4080 with --auth-public '').
Usage:
# Run with MOQ transport (connects to local relay set up by the script):
uv run python examples/transports/transports-moq.py \\
-t moq --moq-cert moq-cert.pem --moq-insecure --moq-path /
# Connect to a remote relay (CA-signed cert, no pinning needed):
uv run python examples/transports/transports-moq.py \\
-t moq --moq-host moq.example.com
# With a custom namespace (different "room"):
uv run python examples/transports/transports-moq.py \\
-t moq --moq-cert moq-cert.pem --moq-insecure --moq-namespace my-room
# Then open the browser client at http://localhost:7860 and click Connect.
# Can also run with other transports (no relay needed):
uv run python examples/transports/transports-moq.py -t webrtc
uv run python examples/transports/transports-moq.py -t daily
"""
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import LLMRunFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import MOQRunnerArguments, RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.transports.moq import MOQParams
from pipecat.transports.moq.protocol import MOQRole
load_dotenv(override=True)
# Transport-specific parameters using lambdas for deferred creation
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"moq": lambda: MOQParams(
audio_in_enabled=True,
audio_out_enabled=True,
role=MOQRole.PUBSUB,
),
}
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
"""Run the bot with the given transport."""
logger.info("Starting bot")
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o")
messages = [
{
"role": "system",
"content": "You are a helpful assistant in a real-time voice call. "
"Your goal is to demonstrate your capabilities in a succinct way. "
"Your output will be spoken aloud, so avoid special characters that can't easily be "
"spoken, such as emojis or bullet points. Respond to what the user said in a creative "
"and helpful way.",
},
]
context = LLMContext(messages)
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(), # Transport user input
stt,
user_aggregator, # User responses
llm, # LLM
tts, # TTS
transport.output(), # Transport bot output
assistant_aggregator, # Assistant spoken responses
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# For MOQ, we need to handle connection and events differently
if isinstance(runner_args, MOQRunnerArguments):
@transport.event_handler("on_connected")
async def on_connected(transport):
logger.info("Connected to MOQ relay (waiting for client to join)")
if runner_args.ready_event is not None:
runner_args.ready_event.set()
@transport.event_handler("on_client_connected")
async def on_client_connected(transport):
logger.info("Client subscribed — starting conversation")
messages.append(
{"role": "system", "content": "Please introduce yourself to the user."}
)
await task.queue_frames([LLMRunFrame()])
@transport.event_handler("on_disconnected")
async def on_disconnected(transport):
logger.info("Disconnected from MOQ relay")
await task.cancel()
@transport.event_handler("on_error")
async def on_error(transport, message, exception):
logger.error(f"MOQ error: {message}")
# MOQInputTransport.start() auto-connects to the relay when the
# pipeline starts, so we don't dial transport.connect() here.
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
try:
await runner.run(task)
finally:
await transport.disconnect()
else:
# Daily and WebRTC use on_client_connected/on_client_disconnected
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
messages.append(
{"role": "system", "content": "Please introduce yourself to the user."}
)
await task.queue_frames([LLMRunFrame()])
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await task.cancel()
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
await runner.run(task)
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat runner."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -50,10 +50,7 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot")
stt = GradiumSTTService(
api_key=os.environ["GRADIUM_API_KEY"],
api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr",
)
stt = GradiumSTTService(api_key=os.environ["GRADIUM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],

View File

@@ -55,7 +55,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
tts = GradiumTTSService(
api_key=os.environ["GRADIUM_API_KEY"],
settings=GradiumTTSService.Settings(voice="YTpq7expH9539ERJ"),
url="wss://us.api.gradium.ai/api/speech/tts",
)
llm = OpenAILLMService(

View File

@@ -54,7 +54,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
stt = GradiumSTTService(
api_key=os.environ["GRADIUM_API_KEY"],
api_endpoint_base_url="wss://us.api.gradium.ai/api/speech/asr",
settings=GradiumSTTService.Settings(
language=Language.EN,
),
@@ -62,7 +61,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
tts = GradiumTTSService(
api_key=os.environ["GRADIUM_API_KEY"],
url="wss://us.api.gradium.ai/api/speech/tts",
settings=GradiumTTSService.Settings(
voice="YTpq7expH9539ERJ",
),

View File

@@ -58,6 +58,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
# Add strict mode to enforce the language hints
language_hints=[Language.EN],
language_hints_strict=True,
enable_language_identification=True,
),
)

View File

@@ -0,0 +1,115 @@
# Plan: `@pipecat-ai/moq-transport`
A future Pipecat Client SDK transport plugin for Media-over-QUIC. Lets any
Pipecat JS UI — including the smallwebrtc playground — talk to a
MOQ-backed bot with zero UI changes, just by swapping the transport.
## Why this exists
The Pipecat Client SDK (`@pipecat-ai/client-js`) is transport-agnostic. UIs
talk to a `PipecatClient`, which delegates to a pluggable `Transport`. The
existing transports are:
- `@pipecat-ai/small-webrtc-transport` (WebRTC)
- `@pipecat-ai/daily-transport` (Daily)
- `@pipecat-ai/websocket-transport` (raw WebSocket / telephony)
- `@pipecat-ai/gemini-live-websocket-transport`
There is **no** `@pipecat-ai/moq-transport`. So MOQ bots today need a hand-
rolled client (which is what `moq_prebuilt/client/` is). Publishing a
proper transport plugin would:
1. Replace `moq_prebuilt/client/` with the prebuilt smallwebrtc UI — same
conversation panel, metrics, device pickers, transcript overlay, etc.
2. Let downstream Pipecat apps drop MOQ in as easily as WebRTC.
3. Centralize the MOQ wire-protocol work in one package instead of every
demo reinventing it.
## What we already have
About 80% of the wire-protocol work is done in
`moq_prebuilt/client/app.js` and `moq_prebuilt/client/moq-client.js`:
- moq-lite-02 codec (CLIENT_SETUP, SERVER_SETUP, ANNOUNCE_PLEASE /
ANNOUNCE_INIT, SUBSCRIBE / SUBSCRIBE_OK, GROUP / FRAME).
- WebTransport bring-up with certificate pinning.
- Per-participant broadcast paths (`<namespace>/<participant_id>`) and
multi-track routing (`bot-audio`, `user-audio`, `transcript`).
- Mic capture via AudioWorklet → 16 kHz PCM publish.
- Bot-audio 24 kHz PCM playback via Web Audio.
- RTVI message parsing on the transcript track (`bot-llm-*`,
`user-transcription`, etc.).
The package's job is to **wrap that in the `Transport` interface** and
emit the events `PipecatClient` expects.
## Effort estimate
### Usable v1 — 35 days
For a single bot / single client dev setup that runs against the
playground UI.
| Day | Work |
| --- | ---- |
| 1 | TypeScript package skeleton (build, tsconfig, ESM/CJS exports, npm scripts). Port existing protocol logic into a class. |
| 2 | Implement `Transport` interface: `connect()`, `disconnect()`, `sendMessage()`, `getDevices()`. Wire the RTVI message parsing on the transcript track into the event names `PipecatClient` expects (`connected`, `disconnected`, `trackStarted`, `userTranscript`, `botStartedSpeaking`, …). |
| 3 | Device handling — mic picker, mute, sample-rate negotiation, AudioWorklet inlined as a string blob. |
| 4 | Run against `ConsoleTemplate` from `@pipecat-ai/voice-ui-kit`. Fix lifecycle bugs (mid-call mute, reconnect, etc.). |
| 5 | Docs, example app, README. |
### Production-grade — ~2 weeks on top
- Multi-participant discovery via outbound `ANNOUNCE_PLEASE` + reacting to
`ANNOUNCE_UPDATE` so multiple bots / multiple clients can share a namespace.
- CA-signed cert path (not just self-signed `serverCertificateHashes`
pinning).
- Reconnection / network-blip recovery.
- Test suite (unit + at least one e2e against a real relay).
- Cross-browser sanity (Chrome reference, Safari WebTransport just
shipped, Firefox still behind a flag).
- npm publish workflow / CI.
## Known unknowns (could blow the estimate)
- **PipecatClient `Transport` surface area** — haven't done a full read of
`@pipecat-ai/client-js`. If the interface expects something MOQ can't
cleanly model (peer-connection introspection, ICE candidates, anything
WebRTC-specific that leaks into the abstraction), we need
workarounds/polyfills.
- **Audio interop with the voice-ui-kit's audio visualizers** —
`<VoicePresence>`, level meters, etc. read from a `MediaStreamTrack`. MOQ
delivers raw PCM uni-streams, not a media-stream-track. We'd need to
synthesize a `MediaStreamTrack` from the decoded PCM so the rest of the
kit just works. Could be a half-day rabbit hole or could be quick — there
are off-the-shelf libraries (`AudioWorklet` + `MediaStreamDestination`) but
the integration details aren't trivial.
## Suggested next step (cheap reconnaissance)
Spend ~2 hours reading:
- `@pipecat-ai/client-js/src/transport.ts` — the `Transport` abstract
interface.
- `@pipecat-ai/small-webrtc-transport` — closest analogue, since both
open a session and stream media bidirectionally.
- `@pipecat-ai/websocket-transport` — simpler reference; might be a closer
fit for MOQ since neither is WebRTC.
Output: a concrete TypeScript API surface for `MoqTransport` (constructor
options, method signatures, events emitted) + a sharper estimate. Two
hours of investment to either commit to the project or pass on it
informedly.
## Open questions for whoever picks this up
- One participant identity per `Transport` instance, or should the
transport manage multiple peers internally (matching how WebRTC SFUs do
it)?
- How should `getDevices()` map to MOQ — pure browser device enumeration
(since there's no SDK-side device negotiation)?
- Where does the per-call ID come from? Server-allocated via
`/start`, or client-generated UUID? (Today we hardcode `bot0` /
`client0`.)
- Cert pinning UX — for prod, can we get away with assuming the relay has
a real CA cert, or do we need an explicit `serverCertificateHashes`
config knob?

0
moq_prebuilt/__init__.py Normal file
View File

878
moq_prebuilt/client/app.js Normal file
View File

@@ -0,0 +1,878 @@
/*
* Copyright (c) 2024-2026, Daily
*
* SPDX-License-Identifier: BSD 2-Clause License
*/
/**
* MOQ browser client — moq-lite-02.
*
* Connects to a moq-lite relay via WebTransport, captures microphone audio,
* and plays back received audio using stream-per-request model.
*
* Flow:
* 1. WebTransport connect
* 2. Open bidi stream for SETUP: write CLIENT_SETUP, read SERVER_SETUP
* 3. Listen for incoming bidi streams (relay sends SUBSCRIBE / ANNOUNCE)
* 4. Listen for incoming uni streams (receive bot audio as GROUP + FRAME)
* 5. Open bidi stream for SUBSCRIBE (to bot's audio track)
* 6. Send mic PCM as uni streams (GROUP + FRAME per chunk)
*/
const {
MOQL_VERSION,
Role,
StreamType,
CLIENT_SETUP_TYPE,
SERVER_SETUP_TYPE,
concat,
encodeClientSetup,
parseServerSetup,
encodeSubscribe,
encodeSubscribeOk,
decodeSubscribe,
encodeGroupAndFrame,
parseGroupStream,
encodeVarint,
decodeVarint,
encodeString,
decodeString,
} = window.MOQ;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let transport = null;
let micStream = null;
let audioContext = null;
let micWorklet = null;
let connected = false;
// Subscription IDs
let nextSubscribeId = 1;
// Publishing state
let pubSubscribeId = null; // set when relay subscribes to our audio
let pubGroupSeq = 0;
// Subscription state (for receiving bot audio and transcript)
let subAudioSubscribeId = null;
let subTranscriptSubscribeId = null;
// Playback
let playbackTime = 0;
// Config (populated from /api/config)
let config = {};
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
function setStatus(text, className) {
const el = document.getElementById("status");
el.textContent = text;
el.className = "status " + (className || "");
}
function log(msg, level = "info") {
if (level === "error") {
console.error("[moq]", msg);
} else if (level === "warn") {
console.warn("[moq]", msg);
} else {
console.log("[moq]", msg);
}
const el = document.getElementById("log");
const line = document.createElement("div");
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
if (level === "error") line.style.color = "#e74c3c";
else if (level === "warn") line.style.color = "#f39c12";
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
function hexdump(data, maxBytes = 40) {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
const hex = Array.from(bytes.slice(0, maxBytes)).map(b => b.toString(16).padStart(2, '0')).join(' ');
return bytes.length > maxBytes ? `${hex}... (${bytes.length} bytes total)` : `${hex} (${bytes.length} bytes)`;
}
// Turn-based transcript: each in-progress LLM/user turn is one row whose
// text accumulates as per-token RTVI messages arrive. Mirrors the
// ConversationProvider model in @pipecat-ai/voice-ui-kit.
//
// A user "turn" runs from the first user-transcription until the bot
// starts speaking — Deepgram can emit several final transcripts inside
// one spoken utterance, and we want all of them in the same bubble.
const turnState = {
assistantRow: null, // DOM <div> for the in-progress assistant turn
assistantText: "", // accumulated text for the in-progress turn
userRow: null, // DOM <div> for the in-progress user turn
userCommitted: "", // accumulated final user text within the turn
userInterim: "", // latest non-final user text (tentative tail)
};
function newTranscriptRow(role) {
const el = document.getElementById("transcript");
if (!el) return null;
const row = document.createElement("div");
const isUser = role === "user";
row.className = `transcript-row transcript-${isUser ? "user" : "assistant"}`;
const label = document.createElement("span");
label.className = "transcript-role";
label.textContent = isUser ? "user" : "assistant";
const body = document.createElement("span");
body.className = "transcript-text";
row.appendChild(label);
row.appendChild(body);
el.appendChild(row);
el.scrollTop = el.scrollHeight;
return row;
}
function setRowText(row, text) {
if (!row) return;
const body = row.querySelector(".transcript-text");
if (body) body.textContent = text;
const el = document.getElementById("transcript");
if (el) el.scrollTop = el.scrollHeight;
}
function clearTranscript() {
const el = document.getElementById("transcript");
if (el) el.innerHTML = "";
turnState.assistantRow = null;
turnState.assistantText = "";
turnState.userRow = null;
turnState.userCommitted = "";
turnState.userInterim = "";
}
// Concatenate streaming tokens with a sensible separator. Some LLM tokens
// already include leading whitespace (e.g. " world"); avoid double spaces.
function appendStreamingText(prev, chunk) {
if (!prev) return chunk;
if (!chunk) return prev;
const needsSpace = !prev.endsWith(" ") && !/^[\s.,!?;:'")\]]/.test(chunk);
return prev + (needsSpace ? " " : "") + chunk;
}
// RTVI message dispatcher. Matches the ConversationProvider semantics from
// @pipecat-ai/voice-ui-kit: per-LLM-turn aggregation for the bot, upsert
// for user transcripts.
function handleRtviMessage(msg) {
if (!msg || typeof msg !== "object") return;
const type = msg.type;
const data = msg.data || {};
switch (type) {
case "bot-llm-started":
// Don't create the row yet — wait for the first bot-llm-text so we
// don't show an empty "assistant" label if no text follows.
turnState.assistantRow = null;
turnState.assistantText = "";
// Bot is responding: the user's turn just ended. The next
// user-transcription should start a fresh row.
turnState.userRow = null;
turnState.userCommitted = "";
turnState.userInterim = "";
break;
case "bot-llm-text":
if (!data.text) break;
if (!turnState.assistantRow) {
// Lazy: covers both the post-started case and pipelines that emit
// bot-llm-text without a preceding bot-llm-started.
turnState.assistantRow = newTranscriptRow("assistant");
}
turnState.assistantText = appendStreamingText(turnState.assistantText, data.text);
setRowText(turnState.assistantRow, turnState.assistantText);
break;
case "bot-llm-stopped":
turnState.assistantRow = null;
turnState.assistantText = "";
break;
case "user-transcription": {
const text = data.text || "";
const final = data.final !== false;
if (!turnState.userRow) {
turnState.userRow = newTranscriptRow("user");
turnState.userCommitted = "";
turnState.userInterim = "";
}
if (final) {
turnState.userCommitted = turnState.userCommitted
? appendStreamingText(turnState.userCommitted, text)
: text;
turnState.userInterim = "";
setRowText(turnState.userRow, turnState.userCommitted);
} else {
turnState.userInterim = text;
const combined = turnState.userCommitted
? appendStreamingText(turnState.userCommitted, text)
: text;
setRowText(turnState.userRow, combined);
}
break;
}
default:
// Ignore other RTVI messages (metrics, speech start/stop, bot-output,
// bot-tts-*, function calls, etc.). Uncomment to discover what's
// flowing on the transcript track.
// log(`RTVI: ${type}`);
break;
}
}
// ---------------------------------------------------------------------------
// Audio playback — queue received 16-bit PCM into Web Audio
// ---------------------------------------------------------------------------
function initAudioPlayback() {
if (audioContext) return;
audioContext = new AudioContext({ sampleRate: 24000 });
playbackTime = audioContext.currentTime;
}
function playPcmChunk(pcmBytes) {
if (!audioContext) return;
// pcmBytes is 16-bit signed LE PCM — copy to aligned buffer to avoid offset issues
const aligned = new ArrayBuffer(pcmBytes.byteLength);
new Uint8Array(aligned).set(pcmBytes);
const samples = new Int16Array(aligned);
const floats = new Float32Array(samples.length);
for (let i = 0; i < samples.length; i++) {
floats[i] = samples[i] / 32768;
}
const buffer = audioContext.createBuffer(1, floats.length, 24000);
buffer.getChannelData(0).set(floats);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
const now = audioContext.currentTime;
if (playbackTime < now) playbackTime = now;
source.start(playbackTime);
playbackTime += buffer.duration;
}
// ---------------------------------------------------------------------------
// Microphone capture — 16kHz 16-bit PCM via AudioWorklet
// ---------------------------------------------------------------------------
const WORKLET_CODE = `
class PcmCapture extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input.length > 0) {
const floats = input[0];
const pcm = new Int16Array(floats.length);
for (let i = 0; i < floats.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(floats[i] * 32768)));
}
this.port.postMessage(pcm.buffer, [pcm.buffer]);
}
return true;
}
}
registerProcessor('pcm-capture', PcmCapture);
`;
async function startMic() {
micStream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true },
});
const ctx = new AudioContext({ sampleRate: 16000 });
const blob = new Blob([WORKLET_CODE], { type: "application/javascript" });
const url = URL.createObjectURL(blob);
await ctx.audioWorklet.addModule(url);
URL.revokeObjectURL(url);
const source = ctx.createMediaStreamSource(micStream);
micWorklet = new AudioWorkletNode(ctx, "pcm-capture");
micWorklet.port.onmessage = (e) => {
if (connected && transport && pubSubscribeId !== null) {
sendAudioUniStream(new Uint8Array(e.data));
}
};
source.connect(micWorklet);
micWorklet.connect(ctx.destination);
log("Microphone started (16kHz PCM)");
}
function stopMic() {
if (micStream) {
micStream.getTracks().forEach((t) => t.stop());
micStream = null;
}
if (micWorklet) {
micWorklet.disconnect();
micWorklet = null;
}
}
// ---------------------------------------------------------------------------
// Uni stream send (publish audio)
// ---------------------------------------------------------------------------
let audioSendCount = 0;
let audioSendErrors = 0;
async function sendAudioUniStream(pcmBytes) {
if (!transport || pubSubscribeId === null) return;
try {
const groupSeq = pubGroupSeq++;
const data = encodeGroupAndFrame(pubSubscribeId, groupSeq, pcmBytes);
const uni = await transport.createUnidirectionalStream();
const writer = uni.getWriter();
await writer.write(data);
await writer.close();
audioSendCount++;
if (audioSendCount % 100 === 0) {
log(`Audio TX: ${audioSendCount} chunks sent (${pcmBytes.byteLength} bytes/chunk, sub=${pubSubscribeId}, seq=${groupSeq})`);
}
} catch (e) {
audioSendErrors++;
if (audioSendErrors <= 5) {
log(`Audio send error #${audioSendErrors}: ${e.message} (sub=${pubSubscribeId}, seq=${pubGroupSeq})`, "warn");
} else if (audioSendErrors === 6) {
log("Suppressing further audio send errors...", "warn");
}
}
}
// ---------------------------------------------------------------------------
// Uni stream receive (receive bot audio)
// ---------------------------------------------------------------------------
let audioRecvCount = 0;
async function receiveUniStreams() {
log("Uni stream listener started (waiting for bot audio)");
const reader = transport.incomingUnidirectionalStreams.getReader();
try {
while (true) {
const { value: stream, done } = await reader.read();
if (done) {
log("Uni stream reader finished (relay closed incoming streams)");
break;
}
handleIncomingUniStream(stream);
}
} catch (e) {
if (connected) log(`Uni stream reader stopped: ${e.message}`, "warn");
}
}
async function handleIncomingUniStream(stream) {
try {
// Read all data from the stream
const reader = stream.getReader();
let buf = new Uint8Array(0);
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf = concat(buf, new Uint8Array(value));
}
if (buf.length === 0) return;
// Parse GROUP + FRAMEs
const { subscribeId, groupSeq, frames } = parseGroupStream(buf);
if (subscribeId === subTranscriptSubscribeId) {
for (const frame of frames) {
if (!frame.byteLength) continue;
try {
const text = new TextDecoder().decode(frame);
handleRtviMessage(JSON.parse(text));
} catch (err) {
log(`Transcript frame decode error: ${err.message}`, "warn");
}
}
return;
}
audioRecvCount++;
if (audioRecvCount <= 3 || audioRecvCount % 100 === 0) {
const totalBytes = frames.reduce((sum, f) => sum + f.byteLength, 0);
log(`Audio RX #${audioRecvCount}: sub=${subscribeId} seq=${groupSeq} frames=${frames.length} bytes=${totalBytes}`);
}
for (const frame of frames) {
if (frame.byteLength > 0) {
playPcmChunk(frame);
}
}
} catch (e) {
log(`Uni stream parse error: ${e.message} (buffer may be malformed)`, "warn");
}
}
// ---------------------------------------------------------------------------
// Incoming bidi stream handler (relay sends SUBSCRIBE / ANNOUNCE)
// ---------------------------------------------------------------------------
let incomingBidiCount = 0;
async function handleIncomingBidiStreams() {
log("Bidi stream listener started (waiting for relay SUBSCRIBE/ANNOUNCE)");
const reader = transport.incomingBidirectionalStreams.getReader();
try {
while (true) {
const { value: stream, done } = await reader.read();
if (done) {
log("Incoming bidi stream reader finished (relay closed)");
break;
}
incomingBidiCount++;
log(`Incoming bidi stream #${incomingBidiCount}`);
handleIncomingBidiStream(stream);
}
} catch (e) {
if (connected) log(`Bidi stream reader stopped: ${e.message}`, "warn");
}
}
async function handleIncomingBidiStream(stream) {
try {
const streamReader = stream.readable.getReader();
let buf = new Uint8Array(0);
// Read all available data (relay sends stream type + message body)
while (true) {
const { value, done } = await streamReader.read();
if (value) buf = concat(buf, new Uint8Array(value));
// Break once we have data or stream ends
if (done || buf.length > 0) break;
}
if (buf.length === 0) {
log("Incoming bidi stream was empty (0 bytes)", "warn");
return;
}
log(`Incoming bidi data: [${hexdump(buf, 30)}]`);
// Decode stream type
const [streamType, offset] = decodeVarint(buf, 0);
const streamTypeName = streamType === StreamType.SUBSCRIBE ? "SUBSCRIBE"
: streamType === StreamType.ANNOUNCE ? "ANNOUNCE"
: streamType === StreamType.SESSION ? "SESSION"
: `UNKNOWN(${streamType})`;
log(`Incoming bidi stream type: ${streamTypeName} (${streamType})`);
if (streamType === StreamType.SUBSCRIBE) {
// We may need more data for the full subscribe message
while (buf.length < offset + 3) {
const { value, done } = await streamReader.read();
if (value) buf = concat(buf, new Uint8Array(value));
if (done) break;
}
// Relay is subscribing to our track
const sub = decodeSubscribe(buf, offset);
log(`SUBSCRIBE from relay: broadcast="${sub.broadcastPath}" track="${sub.trackName}" sub_id=${sub.subscribeId} priority=${sub.priority}`);
// Accept SUBSCRIBE only for our publish track inside our own
// broadcast. With per-participant broadcast paths, the relay only
// routes SUBSCRIBEs targeting <namespace>/<client_id> to us, so a
// mismatch here means something is misconfigured.
const ourBroadcast = `${config.namespace}/${config.client_id}`;
if (sub.broadcastPath !== ourBroadcast || sub.trackName !== config.publish_track) {
log(`Rejecting SUBSCRIBE for "${sub.broadcastPath}/${sub.trackName}" (we only publish "${ourBroadcast}/${config.publish_track}")`, "warn");
stream.writable.abort();
return;
}
// Store subscribe_id for publishing
pubSubscribeId = sub.subscribeId;
// Send SUBSCRIBE_OK on the writable side
const writer = stream.writable.getWriter();
await writer.write(encodeSubscribeOk());
writer.releaseLock();
log(`Sent SUBSCRIBE_OK for sub_id=${sub.subscribeId} — ready to publish ${config.publish_track}`);
} else if (streamType === StreamType.ANNOUNCE) {
// Relay sending ANNOUNCE_PLEASE — read the full message
// Minimum is 2 bytes after stream type: varint(body_len) + varint(string_len)
while (buf.length < offset + 2) {
const { value, done } = await streamReader.read();
if (value) buf = concat(buf, new Uint8Array(value));
if (done) break;
}
// Parse ANNOUNCE_PLEASE: varint(body_len) + string(path_prefix)
let pos = offset;
let bodyLen;
[bodyLen, pos] = decodeVarint(buf, pos);
let pathPrefix = "";
if (bodyLen > 0) {
[pathPrefix, pos] = decodeString(buf, pos);
}
log(`ANNOUNCE_PLEASE from relay: prefix="${pathPrefix}" bodyLen=${bodyLen}`);
// Respond with ANNOUNCE_INIT for our per-participant broadcast path.
// Each participant uses a distinct suffix (e.g. "pipecat/client0")
// so the relay can route SUBSCRIBEs to the right side.
const broadcast = `${config.namespace}/${config.client_id}`;
let suffix = broadcast;
if (pathPrefix) {
if (broadcast.startsWith(pathPrefix)) {
suffix = broadcast.slice(pathPrefix.length).replace(/^\//, "");
} else {
suffix = null;
}
}
const writer = stream.writable.getWriter();
if (suffix !== null) {
let body = encodeVarint(1); // 1 suffix
body = concat(body, encodeString(suffix));
const initMsg = concat(encodeVarint(body.length), body);
await writer.write(initMsg);
log(`Sent ANNOUNCE_INIT: 1 suffix="${suffix}" [${hexdump(initMsg)}]`);
} else {
const initMsg = concat(encodeVarint(1), encodeVarint(0));
await writer.write(initMsg);
log(`Sent ANNOUNCE_INIT: 0 suffixes (our broadcast doesn't match prefix "${pathPrefix}")`);
}
writer.releaseLock();
} else {
log(`Unknown bidi stream type ${streamType}, raw: [${hexdump(buf)}]`, "warn");
}
} catch (e) {
log(`Incoming bidi stream error: ${e.message}`, "error");
}
}
// ---------------------------------------------------------------------------
// Connect flow
// ---------------------------------------------------------------------------
async function doConnect() {
const t0 = performance.now();
clearTranscript();
try {
// 1. Fetch config from the FastAPI server
log("Fetching /api/config...");
const resp = await fetch("/api/config");
config = await resp.json();
log(`Config: relay=${config.relay_host}:${config.relay_port}, path="${config.path}", ns="${config.namespace}", client_id="${config.client_id}", bot_id="${config.bot_id}", publish="${config.publish_track}", subscribe="${config.subscribe_track}", insecure=${config.insecure}, cert_hash=${config.cert_hash ? config.cert_hash.slice(0, 12) + "..." : "none"}`);
// 1b. Ask the server to start the bot. It blocks until the bot has
// finished its MOQ handshake with the relay, so our SUBSCRIBE is
// guaranteed to land at a publisher the relay already knows.
log("Starting bot via /start...");
const startResp = await fetch("/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namespace: config.namespace }),
});
if (!startResp.ok) {
throw new Error(`/start returned HTTP ${startResp.status}`);
}
const startInfo = await startResp.json();
log(`Bot started: session=${startInfo.sessionId}, relay=${startInfo.relay}`);
// 2. Build relay URL
const scheme = "https";
const host = config.relay_host;
const relayUrl = `${scheme}://${host}:${config.relay_port}${config.path || "/moq"}`;
log(`Connecting to ${relayUrl}`);
setStatus("Connecting...", "connecting");
// 3. Open WebTransport (connects via HTTP/3 over QUIC to the relay)
const options = {};
if (config.cert_hash) {
const hashBytes = Uint8Array.from(atob(config.cert_hash), c => c.charCodeAt(0));
options.serverCertificateHashes = [
{ algorithm: "sha-256", value: hashBytes.buffer },
];
log(`Using certificate pinning: sha256=${config.cert_hash.slice(0, 16)}...`);
} else {
log("No certificate pinning (no cert_hash in config)", "warn");
}
log(`WebTransport options: ${JSON.stringify({...options, serverCertificateHashes: options.serverCertificateHashes ? "[present]" : undefined})}`);
transport = new WebTransport(relayUrl, options);
// Monitor connection closure — extract QUIC error details
transport.closed.then(info => {
const elapsed = (performance.now() - t0).toFixed(0);
log(`Transport closed cleanly after ${elapsed}ms: closeCode=${info?.closeCode} reason="${info?.reason || ""}"`);
}).catch(err => {
const elapsed = (performance.now() - t0).toFixed(0);
// WebTransportCloseInfo may have closeCode and reason
const code = err?.closeCode ?? err?.code ?? "?";
const reason = err?.reason ?? err?.message ?? String(err);
log(`Transport closed with error after ${elapsed}ms: code=${code} reason="${reason}"`, "error");
log(` Error type: ${err?.constructor?.name}, keys: ${Object.keys(err || {}).join(",")}`, "error");
});
// Wait for the QUIC + HTTP/3 handshake
const readyStart = performance.now();
try {
await transport.ready;
} catch (readyErr) {
const readyMs = (performance.now() - readyStart).toFixed(0);
// Get the real error from transport.closed
let closedErr = null;
try { await transport.closed; } catch (e) { closedErr = e; }
const detail = closedErr?.message || closedErr?.reason || String(closedErr || "");
const code = closedErr?.closeCode ?? closedErr?.code ?? "?";
log(`WebTransport handshake FAILED after ${readyMs}ms`, "error");
log(` ready error: ${readyErr.message}`, "error");
log(` closed error: code=${code} detail="${detail}"`, "error");
log(` URL: ${relayUrl}`, "error");
log(` Cert pinning: ${config.cert_hash ? "yes" : "no"}`, "error");
log(`Troubleshooting:`, "error");
log(` - Is the relay running at ${host}:${config.relay_port}?`, "error");
log(` - Does relay config have [web.http] listen = "[::]:${config.relay_port}"?`, "error");
log(` - If using tls.generate in relay config, cert hash will change every restart`, "error");
log(` - Try 127.0.0.1 instead of localhost (QUIC/UDP has no IPv6->IPv4 fallback)`, "error");
throw readyErr;
}
const readyMs = (performance.now() - readyStart).toFixed(0);
log(`WebTransport connected (handshake took ${readyMs}ms)`);
// 4. Open bidi stream for SETUP handshake
log("Opening bidi stream for CLIENT_SETUP...");
let setupBidi;
try {
setupBidi = await transport.createBidirectionalStream();
log("Bidi stream created for SETUP");
} catch (e) {
// This is the "Connection lost" case — get more details
let closedErr = null;
try { await transport.closed; } catch (ce) { closedErr = ce; }
const code = closedErr?.closeCode ?? closedErr?.code ?? "?";
const reason = closedErr?.reason ?? closedErr?.message ?? "";
log(`Failed to create bidi stream: ${e.message}`, "error");
log(` Underlying close: code=${code} reason="${reason}"`, "error");
log(` This usually means the relay rejected the WebTransport session.`, "error");
log(` Check the relay terminal for error logs (auth failure, path mismatch, etc.)`, "error");
log(` Relay URL path was: "${config.path || "/moq"}" — does the relay expect this path?`, "error");
throw e;
}
const setupWriter = setupBidi.writable.getWriter();
const setupStreamReader = setupBidi.readable.getReader();
// Write stream type varint(0x20) + CLIENT_SETUP (u16 body size for WebTransport)
const setupMsg = encodeClientSetup(Role.PUBSUB, [MOQL_VERSION], config.path || "/moq");
const fullSetup = concat(encodeVarint(CLIENT_SETUP_TYPE), setupMsg);
log(`Sending CLIENT_SETUP: role=PUBSUB version=0x${MOQL_VERSION.toString(16)} path="${config.path || "/moq"}" [${hexdump(fullSetup)}]`);
try {
await setupWriter.write(fullSetup);
log("CLIENT_SETUP sent, waiting for SERVER_SETUP...");
} catch (e) {
log(`Failed to write CLIENT_SETUP: ${e.message}`, "error");
throw e;
}
// 5. Read SERVER_SETUP response
const setupTimeout = setTimeout(() => {
log("SERVER_SETUP not received after 5s — relay may not understand our protocol", "warn");
}, 5000);
const { value: setupResp, done: setupDone } = await setupStreamReader.read();
clearTimeout(setupTimeout);
if (setupDone && !setupResp) {
log("Setup stream closed by relay without sending SERVER_SETUP", "error");
log(" The relay may not support moq-lite-02 (0xff0dad02)", "error");
throw new Error("No SERVER_SETUP received");
}
if (setupResp) {
const respData = new Uint8Array(setupResp);
log(`Received SERVER_SETUP raw: [${hexdump(respData)}]`);
// Skip stream type varint (0x21) if present
let offset = 0;
if (respData[0] === SERVER_SETUP_TYPE) {
offset = 1;
} else {
const [st, newOff] = decodeVarint(respData, 0);
if (st === SERVER_SETUP_TYPE) offset = newOff;
}
const { version } = parseServerSetup(respData.subarray(offset));
const versionName = version === 0xff0dad02 ? "moq-lite-02" : `unknown (0x${version.toString(16)})`;
log(`SERVER_SETUP: relay version=${versionName} (0x${version.toString(16)}), client version=moq-lite-02 (0x${MOQL_VERSION.toString(16)})`);
if (version !== MOQL_VERSION) {
log(`PROTOCOL MISMATCH! Browser speaks moq-lite-02 but relay speaks ${versionName}. Things will likely break.`, "error");
}
}
// Keep setup stream open — don't close it
setupWriter.releaseLock();
connected = true;
const totalMs = (performance.now() - t0).toFixed(0);
setStatus("Connected", "connected");
log(`Connected to relay (total setup took ${totalMs}ms)`);
// 6. Start listening for incoming streams FIRST
// (relay may send ANNOUNCE_PLEASE and SUBSCRIBE before we subscribe)
log("Listening for incoming bidi + uni streams...");
handleIncomingBidiStreams();
receiveUniStreams();
// 7. Init audio playback (needs user gesture — we're inside a click handler)
initAudioPlayback();
log("Audio playback initialized (24kHz)");
// 8. Start mic (so we're ready to publish when relay subscribes)
await startMic();
// 9. Subscribe to bot's audio + transcript tracks (non-fatal if bot
// isn't connected yet)
await subscribeToAudio();
subscribeToTranscript();
} catch (e) {
const elapsed = (performance.now() - t0).toFixed(0);
log(`Connection failed after ${elapsed}ms: ${e.message}`, "error");
if (e.stack) log(` Stack: ${e.stack.split("\n").slice(1, 3).join(" | ")}`, "error");
if (e.stack) log(` Stack: ${e.stack}`);
setStatus("Error: " + e.message, "error");
}
}
async function subscribeToAudio(retryCount = 0) {
const botBroadcast = `${config.namespace}/${config.bot_id}`;
const botTrack = config.subscribe_track;
const fullPath = `${botBroadcast}/${botTrack}`;
const maxRetries = 10;
const retryDelay = 2000; // 2 seconds between retries
subAudioSubscribeId = nextSubscribeId++;
try {
// Open a new bidi stream for SUBSCRIBE
log(`Subscribing to ${fullPath} (sub_id=${subAudioSubscribeId}, attempt ${retryCount + 1}/${maxRetries + 1})`);
const subBidi = await transport.createBidirectionalStream();
const subWriter = subBidi.writable.getWriter();
const subReader = subBidi.readable.getReader();
const subMsg = encodeSubscribe(subAudioSubscribeId, botBroadcast, botTrack);
log(`Sending SUBSCRIBE: [${hexdump(subMsg)}]`);
await subWriter.write(subMsg);
// Read SUBSCRIBE_OK (or handle RESET_STREAM gracefully)
log("Waiting for SUBSCRIBE_OK from relay...");
const { value: okResp, done } = await subReader.read();
if (okResp) {
const okData = new Uint8Array(okResp);
log(`Received SUBSCRIBE_OK for ${fullPath}: [${hexdump(okData)}]`);
return; // Success
} else if (done) {
log(`SUBSCRIBE stream closed by relay without response (track "${fullPath}" may not exist — bot not connected yet?)`, "warn");
}
subWriter.releaseLock();
} catch (e) {
// RESET_STREAM means the track doesn't exist yet — not fatal
log(`Subscribe to ${fullPath} failed: ${e.message}`, "warn");
log(` (This is normal if the bot hasn't connected to the relay yet)`, "warn");
}
// Retry if bot isn't connected yet
if (connected && retryCount < maxRetries) {
log(`Will retry subscribe in ${retryDelay / 1000}s (attempt ${retryCount + 1}/${maxRetries})...`);
setTimeout(() => subscribeToAudio(retryCount + 1), retryDelay);
} else if (retryCount >= maxRetries) {
log(`Gave up subscribing to ${fullPath} after ${maxRetries} attempts`, "error");
}
}
async function subscribeToTranscript() {
const trackName = config.transcript_track || "transcript";
const botBroadcast = `${config.namespace}/${config.bot_id}`;
const fullPath = `${botBroadcast}/${trackName}`;
subTranscriptSubscribeId = nextSubscribeId++;
try {
log(`Subscribing to ${fullPath} (sub_id=${subTranscriptSubscribeId})`);
const subBidi = await transport.createBidirectionalStream();
const subWriter = subBidi.writable.getWriter();
const subReader = subBidi.readable.getReader();
const subMsg = encodeSubscribe(subTranscriptSubscribeId, botBroadcast, trackName);
await subWriter.write(subMsg);
subWriter.releaseLock();
// Keep the reader open so the relay can stream SUBSCRIBE_OK / errors.
subReader.read().then(({ value, done }) => {
if (value && value.byteLength) {
log(`Transcript SUBSCRIBE_OK: [${hexdump(new Uint8Array(value))}]`);
} else if (done) {
log(`Transcript subscribe stream closed by relay (track may not exist)`, "warn");
}
}).catch(() => {});
} catch (e) {
log(`Subscribe to ${fullPath} failed: ${e.message}`, "warn");
}
}
async function doDisconnect() {
log(`Disconnecting... (sent=${audioSendCount} chunks, received=${audioRecvCount} chunks, errors=${audioSendErrors})`);
connected = false;
stopMic();
if (transport) {
try {
transport.close();
log("WebTransport closed");
} catch (e) {
log(`Error closing transport: ${e.message}`, "warn");
}
transport = null;
}
pubSubscribeId = null;
pubGroupSeq = 0;
subAudioSubscribeId = null;
subTranscriptSubscribeId = null;
nextSubscribeId = 1;
audioSendCount = 0;
audioSendErrors = 0;
audioRecvCount = 0;
incomingBidiCount = 0;
if (audioContext) {
audioContext.close();
audioContext = null;
}
setStatus("Disconnected", "disconnected");
log("Disconnected");
document.getElementById("connectBtn").disabled = false;
document.getElementById("disconnectBtn").disabled = true;
}
// ---------------------------------------------------------------------------
// Button handlers
// ---------------------------------------------------------------------------
document.getElementById("connectBtn").addEventListener("click", async () => {
document.getElementById("connectBtn").disabled = true;
document.getElementById("disconnectBtn").disabled = false;
await doConnect();
});
document.getElementById("disconnectBtn").addEventListener("click", async () => {
await doDisconnect();
});

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pipecat MOQ Client</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<h1>Pipecat MOQ Client</h1>
<div class="card">
<h3>Connection</h3>
<div id="status" class="status disconnected">Disconnected</div>
<div class="controls">
<button id="connectBtn">Connect</button>
<button id="disconnectBtn" disabled>Disconnect</button>
</div>
</div>
<div class="card transcript-card">
<h3>Transcript</h3>
<div id="transcript" class="transcript"></div>
</div>
<div class="card log-card">
<h3>Log</h3>
<div id="log" class="log"></div>
</div>
</div>
<script src="moq-client.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
/*
* Copyright (c) 2024-2026, Daily
*
* SPDX-License-Identifier: BSD 2-Clause License
*/
/**
* MOQ (Media over QUIC) protocol client — moq-lite-02.
*
* Implements varint codec, message encode/decode for moq-lite-02 protocol
* so the browser can talk to a moq-lite relay over WebTransport.
*
* Key differences from draft-07:
* - Stream-per-request model (no shared control stream)
* - Setup uses u8(0x20/0x21) framing; via WebTransport uses u16 body size
* - SUBSCRIBE/ANNOUNCE each get their own bidi stream
* - Media data flows on uni streams as GROUP + FRAME
* - No QUIC datagrams
*/
(function () {
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MOQL_VERSION = 0xff0dad02; // moq-lite-02
const Role = Object.freeze({
PUBLISHER: 0x01,
SUBSCRIBER: 0x02,
PUBSUB: 0x03,
});
// Stream type varints (first thing written on a new bidi stream)
const StreamType = Object.freeze({
SESSION: 0,
ANNOUNCE: 1,
SUBSCRIBE: 2,
});
// Setup message type bytes
const CLIENT_SETUP_TYPE = 0x20;
const SERVER_SETUP_TYPE = 0x21;
// Uni stream type
const UNI_STREAM_TYPE_GROUP = 0;
// ---------------------------------------------------------------------------
// QUIC variable-length integer codec
// ---------------------------------------------------------------------------
function encodeVarint(value) {
if (value < 0x40) {
return new Uint8Array([value]);
} else if (value < 0x4000) {
const buf = new Uint8Array(2);
new DataView(buf.buffer).setUint16(0, value | 0x4000);
return buf;
} else if (value < 0x40000000) {
const buf = new Uint8Array(4);
new DataView(buf.buffer).setUint32(0, (value | 0x80000000) >>> 0);
return buf;
} else {
const buf = new Uint8Array(8);
const dv = new DataView(buf.buffer);
const hi = Math.floor(value / 0x100000000) | 0xc0000000;
const lo = value >>> 0;
dv.setUint32(0, hi >>> 0);
dv.setUint32(4, lo);
return buf;
}
}
function decodeVarint(data, offset) {
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
const first = data[offset];
const lengthBits = first >> 6;
if (lengthBits === 0) {
return [first, offset + 1];
} else if (lengthBits === 1) {
return [dv.getUint16(offset) & 0x3fff, offset + 2];
} else if (lengthBits === 2) {
return [dv.getUint32(offset) & 0x3fffffff, offset + 4];
} else {
const hi = dv.getUint32(offset) & 0x3fffffff;
const lo = dv.getUint32(offset + 4);
return [hi * 0x100000000 + lo, offset + 8];
}
}
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function encodeString(str) {
const bytes = encoder.encode(str);
return concat(encodeVarint(bytes.length), bytes);
}
function decodeString(data, offset) {
const [len, off] = decodeVarint(data, offset);
const str = decoder.decode(data.subarray(off, off + len));
return [str, off + len];
}
// ---------------------------------------------------------------------------
// Buffer helpers
// ---------------------------------------------------------------------------
function concat(...arrays) {
let total = 0;
for (const a of arrays) total += a.byteLength;
const result = new Uint8Array(total);
let pos = 0;
for (const a of arrays) {
result.set(a instanceof Uint8Array ? a : new Uint8Array(a), pos);
pos += a.byteLength;
}
return result;
}
// ---------------------------------------------------------------------------
// Setup messages (WebTransport uses u16 body size prefix)
// ---------------------------------------------------------------------------
/**
* Encode CLIENT_SETUP for WebTransport (browser).
*
* WebTransport framing: varint(0x20) as stream type, then u16(body_len) + body
* on a dedicated bidi stream.
*/
function encodeClientSetup(role, versions, path) {
let body = encodeVarint(versions.length);
for (const v of versions) {
body = concat(body, encodeVarint(v));
}
// Parameters
let numParams = 1; // role
if (path) numParams += 1;
body = concat(body, encodeVarint(numParams));
// Role param (key=0)
body = concat(body, encodeVarint(0), encodeVarint(1), encodeVarint(role));
// Path param (key=1)
if (path) {
const pathBytes = encoder.encode(path);
body = concat(body, encodeVarint(1), encodeVarint(pathBytes.length), pathBytes);
}
// WebTransport framing: u16(body_len) + body
const frame = new Uint8Array(2 + body.length);
new DataView(frame.buffer).setUint16(0, body.length);
frame.set(body, 2);
return frame;
}
/**
* Parse SERVER_SETUP from WebTransport.
*
* WebTransport framing: u16(body_len) + body
* Body: varint(selected_version) + params...
*/
function parseServerSetup(data) {
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
const bodyLen = dv.getUint16(0);
let offset = 2;
const [version, newOff] = decodeVarint(data, offset);
return { version, bodyEnd: 2 + bodyLen };
}
// ---------------------------------------------------------------------------
// Subscribe messages (on dedicated bidi stream with stream_type=2)
// ---------------------------------------------------------------------------
/**
* Encode SUBSCRIBE message body.
*
* Stream format: varint(2) + varint(body_len) + body
* Body: varint(sub_id) + string(broadcast_path) + string(track_name) + u8(priority)
*/
function encodeSubscribe(subscribeId, broadcastPath, trackName, priority = 128) {
let body = concat(
encodeVarint(subscribeId),
encodeString(broadcastPath),
encodeString(trackName),
new Uint8Array([priority]),
);
// Stream type + length-prefixed body
return concat(
encodeVarint(StreamType.SUBSCRIBE),
encodeVarint(body.length),
body,
);
}
/**
* Encode SUBSCRIBE_OK response.
* Format: varint(0) — empty body.
*/
function encodeSubscribeOk() {
return encodeVarint(0);
}
/**
* Decode a SUBSCRIBE message from an incoming bidi stream.
* Input starts after the stream type varint has been consumed.
*/
function decodeSubscribe(data, offset = 0) {
let bodyLen;
[bodyLen, offset] = decodeVarint(data, offset);
const bodyEnd = offset + bodyLen;
let subscribeId, broadcastPath, trackName;
[subscribeId, offset] = decodeVarint(data, offset);
[broadcastPath, offset] = decodeString(data, offset);
[trackName, offset] = decodeString(data, offset);
const priority = data[offset];
offset += 1;
return { subscribeId, broadcastPath, trackName, priority, end: bodyEnd };
}
// ---------------------------------------------------------------------------
// GROUP + FRAME messages (on unidirectional streams)
// ---------------------------------------------------------------------------
/**
* Encode GROUP header + single FRAME for a uni stream.
*
* Format: u8(0) + varint(header_body_len) + varint(subscribe_id) + varint(group_seq)
* + varint(payload_len) + payload
*/
function encodeGroupAndFrame(subscribeId, groupSeq, payload) {
const headerBody = concat(
encodeVarint(subscribeId),
encodeVarint(groupSeq),
);
const frame = concat(
encodeVarint(payload.byteLength),
payload instanceof Uint8Array ? payload : new Uint8Array(payload),
);
return concat(
new Uint8Array([UNI_STREAM_TYPE_GROUP]),
encodeVarint(headerBody.length),
headerBody,
frame,
);
}
/**
* Parse GROUP header + FRAMEs from a complete uni stream buffer.
*
* Returns { subscribeId, groupSeq, frames: [Uint8Array, ...] }
*/
function parseGroupStream(data) {
let offset = 0;
// u8(0) stream type
const streamType = data[offset];
offset += 1;
// varint(body_len)
let bodyLen;
[bodyLen, offset] = decodeVarint(data, offset);
const bodyStart = offset;
// varint(subscribe_id)
let subscribeId;
[subscribeId, offset] = decodeVarint(data, offset);
// varint(group_seq)
let groupSeq;
[groupSeq, offset] = decodeVarint(data, offset);
// Advance past header body
offset = bodyStart + bodyLen;
// Parse frames: varint(payload_len) + payload, repeated
const frames = [];
while (offset < data.length) {
let payloadLen;
[payloadLen, offset] = decodeVarint(data, offset);
frames.push(data.subarray(offset, offset + payloadLen));
offset += payloadLen;
}
return { subscribeId, groupSeq, frames };
}
// ---------------------------------------------------------------------------
// Exports (global for vanilla JS)
// ---------------------------------------------------------------------------
window.MOQ = {
MOQL_VERSION,
Role,
StreamType,
CLIENT_SETUP_TYPE,
SERVER_SETUP_TYPE,
encodeVarint,
decodeVarint,
encodeString,
decodeString,
concat,
encodeClientSetup,
parseServerSetup,
encodeSubscribe,
encodeSubscribeOk,
decodeSubscribe,
encodeGroupAndFrame,
parseGroupStream,
};
})();

View File

@@ -0,0 +1,242 @@
/* Palette borrowed from @pipecat-ai/voice-ui-kit (dark theme) so this
custom MOQ client visually matches the smallwebrtc playground. */
:root {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--border: oklch(1 0 0 / 10%);
--border-strong: oklch(1 0 0 / 16%);
--primary: oklch(0.85 0.13 162); /* green Connect */
--primary-hover: oklch(0.78 0.14 162);
--destructive: oklch(0.577 0.245 27.325); /* red Disconnect */
--destructive-hover: oklch(0.52 0.225 27.325);
--warning: oklch(0.795 0.184 86.047);
--user-bubble: oklch(0.305 0.045 265);
--assistant-bubble: oklch(0.255 0.012 286);
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", "JetBrains Mono", "Fira Code", "Cascadia Code", Consolas, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 48px 24px 80px;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 720px;
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
h1 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.005em;
color: var(--foreground);
margin: 0 0 8px 4px;
}
/* ---------- Card ---------- */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 20px;
}
.card h3 {
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted-foreground);
margin-bottom: 14px;
}
/* ---------- Connection card (status + buttons) ---------- */
.status {
font-size: 1rem;
font-weight: 500;
padding: 4px 0 14px;
color: var(--muted-foreground);
display: flex;
align-items: center;
gap: 10px;
}
.status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted-foreground);
flex-shrink: 0;
}
.status.disconnected { color: var(--muted-foreground); }
.status.disconnected::before { background: var(--muted-foreground); }
.status.connecting { color: var(--warning); }
.status.connecting::before { background: var(--warning); }
.status.connected { color: var(--primary); }
.status.connected::before { background: var(--primary); box-shadow: 0 0 0 4px oklch(0.85 0.13 162 / 0.15); }
.status.error { color: var(--destructive); }
.status.error::before { background: var(--destructive); }
.controls {
display: flex;
gap: 8px;
}
button {
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.01em;
padding: 8px 18px;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
#connectBtn {
background: var(--primary);
color: oklch(0.16 0.02 162);
}
#connectBtn:hover:not(:disabled) {
background: var(--primary-hover);
}
#disconnectBtn {
background: transparent;
border-color: var(--destructive);
color: var(--destructive);
}
#disconnectBtn:hover:not(:disabled) {
background: oklch(0.577 0.245 27.325 / 0.15);
}
/* ---------- Transcript ---------- */
.transcript {
font-size: 0.95rem;
line-height: 1.55;
max-height: 360px;
min-height: 48px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.transcript:empty::before {
content: "Waiting for conversation…";
color: var(--muted-foreground);
font-style: italic;
opacity: 0.6;
}
.transcript-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 14px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
max-width: 88%;
align-self: flex-start;
background: var(--assistant-bubble);
}
.transcript-user {
background: var(--user-bubble);
}
.transcript-role {
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.transcript-text {
color: var(--foreground);
white-space: pre-wrap;
word-wrap: break-word;
}
/* ---------- Log ---------- */
.log {
font-family: var(--font-mono);
font-size: 0.72rem;
line-height: 1.7;
max-height: 360px;
overflow-y: auto;
color: var(--muted-foreground);
}
.log div {
padding: 2px 0;
border-bottom: 1px solid oklch(1 0 0 / 0.04);
}
.log div:last-child {
border-bottom: none;
}
/* ---------- Scrollbar polish ---------- */
.transcript::-webkit-scrollbar,
.log::-webkit-scrollbar {
width: 6px;
}
.transcript::-webkit-scrollbar-thumb,
.log::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
.transcript::-webkit-scrollbar-track,
.log::-webkit-scrollbar-track {
background: transparent;
}

31
moq_prebuilt/frontend.py Normal file
View File

@@ -0,0 +1,31 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import logging
import os
from fastapi.staticfiles import StaticFiles
# Define possible paths to the client directory
base_dir = os.path.dirname(__file__)
possible_client_paths = [
os.path.abspath(os.path.join(base_dir, "client")), # in package
os.path.abspath(os.path.join(base_dir, "..", "client")), # in dev
]
client_dir = None
for path in possible_client_paths:
logging.info(f"Checking MOQ client directory: {path}")
if os.path.isdir(path):
client_dir = path
break
if not client_dir:
logging.error("MOQ prebuilt client not found in any of the expected locations.")
raise RuntimeError("MOQ prebuilt client not found.")
MOQPrebuiltUI = StaticFiles(directory=client_dir, html=True)

View File

@@ -91,6 +91,7 @@ mem0 = [ "mem0ai>=1.0.8,<2" ]
mistral = ["mistralai>=2.0.0,<3"]
mlx-whisper = [ "mlx-whisper~=0.4.2" ]
moondream = [ "accelerate~=1.10.0", "einops~=0.8.0", "pyvips[binary]~=3.0.0", "timm~=1.0.13", "transformers>=4.48.0,<6" ]
moq = [ "aioquic>=1.2.0,<2", "cryptography>=43.0.0" ]
nebius = []
neuphonic = [ "pipecat-ai[websockets-base]" ]
novita = []

98
scripts/moq-dev-setup.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Generate a self-signed cert for local MOQ dev, symlink it into both the
# relay repo and this pipecat repo, and print the commands to run everything.
#
# Usage:
# ./scripts/moq-dev-setup.sh /path/to/moq-relay
#
set -euo pipefail
RELAY_DIR="${1:?Usage: $0 /path/to/moq-relay}"
PIPECAT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Resolve relay dir to absolute path
RELAY_DIR="$(cd "$RELAY_DIR" && pwd)"
CERT_DIR="$PIPECAT_DIR/.moq-certs"
CERT_FILE="$CERT_DIR/moq-cert.pem"
KEY_FILE="$CERT_DIR/moq-key.pem"
echo "==> Relay dir: $RELAY_DIR"
echo "==> Pipecat dir: $PIPECAT_DIR"
echo "==> Cert dir: $CERT_DIR"
echo
# ---------------------------------------------------------------------------
# 1. Generate cert + key
# ---------------------------------------------------------------------------
mkdir -p "$CERT_DIR"
echo "==> Generating self-signed cert (valid 14 days — MOQ max)..."
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-days 14 \
-nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
2>/dev/null
echo " $CERT_FILE"
echo " $KEY_FILE"
echo
# ---------------------------------------------------------------------------
# 2. Compute SHA-256 fingerprint (used by WebTransport cert pinning)
# ---------------------------------------------------------------------------
FINGERPRINT=$(openssl x509 -in "$CERT_FILE" -outform der \
| openssl dgst -sha256 -binary \
| base64)
echo "==> Certificate SHA-256 fingerprint:"
echo " $FINGERPRINT"
echo
# ---------------------------------------------------------------------------
# 3. Symlink into relay dir and pipecat dir
# ---------------------------------------------------------------------------
echo "==> Symlinking certs..."
for DIR in "$RELAY_DIR" "$PIPECAT_DIR"; do
for FILE in "$CERT_FILE" "$KEY_FILE"; do
BASENAME="$(basename "$FILE")"
TARGET="$DIR/$BASENAME"
rm -f "$TARGET"
ln -s "$FILE" "$TARGET"
echo " $TARGET -> $FILE"
done
done
echo
# ---------------------------------------------------------------------------
# 4. Print run commands
# ---------------------------------------------------------------------------
echo "==========================================="
echo " Run these in separate terminals:"
echo "==========================================="
echo
echo "# 1. Start the relay (binds QUIC/WebTransport on UDP [::]:4080)"
echo "cd $RELAY_DIR"
echo "cargo run --bin moq-relay -- \\"
echo " --server-bind '[::]:4080' \\"
echo " --tls-cert moq-cert.pem \\"
echo " --tls-key moq-key.pem \\"
echo " --auth-public ''"
echo
echo "# 2. Start the bot"
echo "cd $PIPECAT_DIR"
echo "uv run python examples/transports/transports-moq.py \\"
echo " -t moq \\"
echo " --moq-cert moq-cert.pem \\"
echo " --moq-insecure \\"
echo " --moq-path /"
echo
echo "# 3. Open browser"
echo "open http://localhost:7860"
echo

View File

@@ -53,6 +53,7 @@ Supported transports:
- Daily - Creates rooms and tokens, runs bot as participant
- WebRTC - Provides local WebRTC interface with prebuilt UI
- MOQ - Media over QUIC, connects to a MOQ relay for pub/sub streaming
- Telephony - Handles webhook and WebSocket connections for Twilio, Telnyx, Plivo, Exotel
To run locally:
@@ -61,6 +62,7 @@ To run locally:
- ESP32: `python bot.py -t webrtc --esp32 --host 192.168.1.100`
- Daily (server): `python bot.py -t daily`
- Daily (direct, testing only): `python bot.py -d`
- MOQ: `python bot.py -t moq --moq-host relay.example.com --moq-insecure`
- Telephony: `python bot.py -t twilio -x your_username.ngrok.io`
- Exotel: `python bot.py -t exotel` (no proxy needed, but ngrok connection to HTTP 7860 is required)
"""
@@ -83,6 +85,7 @@ from loguru import logger
from pipecat.runner.types import (
DailyRunnerArguments,
MOQRunnerArguments,
RunnerArguments,
SmallWebRTCRunnerArguments,
WebSocketRunnerArguments,
@@ -93,7 +96,7 @@ try:
from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
except ImportError as e:
logger.error(f"Runner dependencies not available: {e}")
logger.error("To use Pipecat runners, install with: pip install pipecat-ai[runner]")
@@ -203,6 +206,8 @@ def _configure_server_app(args: argparse.Namespace):
_setup_whatsapp_routes(app, args)
elif args.transport == "daily":
_setup_daily_routes(app, args)
elif args.transport == "moq":
_setup_moq_routes(app, args)
elif args.transport in TELEPHONY_TRANSPORTS:
_setup_telephony_routes(app, args)
else:
@@ -800,6 +805,131 @@ def _setup_daily_routes(app: FastAPI, args: argparse.Namespace):
}
def _setup_moq_routes(app: FastAPI, args: argparse.Namespace):
"""Set up MOQ (Media over QUIC) specific routes."""
MOQPrebuiltUI = None
try:
from moq_prebuilt.frontend import MOQPrebuiltUI
except ImportError:
# Fallback: look for moq_prebuilt relative to the pipecat repo root
try:
import pipecat
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(pipecat.__file__)))
client_dir = os.path.join(repo_root, "moq_prebuilt", "client")
if os.path.isdir(client_dir):
from starlette.staticfiles import StaticFiles
MOQPrebuiltUI = StaticFiles(directory=client_dir, html=True)
except Exception:
pass
if not MOQPrebuiltUI:
logger.warning("moq_prebuilt client not found, MOQ client UI will not be available")
# Track active MOQ sessions
moq_sessions: Dict[str, Any] = {}
# Mount the frontend
if MOQPrebuiltUI:
app.mount("/client", MOQPrebuiltUI)
@app.get("/", include_in_schema=False)
async def root_redirect():
"""Redirect root requests to client interface."""
return RedirectResponse(url="/client/")
@app.get("/api/config")
async def moq_config():
"""Return MOQ relay connection config for the browser client."""
cert_hash = None
if getattr(args, "moq_cert", None):
try:
import base64
import hashlib
from cryptography import x509
from cryptography.hazmat.primitives import serialization
with open(args.moq_cert, "rb") as f:
cert = x509.load_pem_x509_certificate(f.read())
der_bytes = cert.public_bytes(serialization.Encoding.DER)
digest = hashlib.sha256(der_bytes).digest()
cert_hash = base64.b64encode(digest).decode()
except Exception as e:
logger.warning(f"Could not compute cert fingerprint: {e}")
return {
"relay_host": args.moq_host,
"relay_port": args.moq_port,
"path": args.moq_path,
"namespace": args.moq_namespace,
"insecure": args.moq_insecure,
"cert_hash": cert_hash,
# Per-participant broadcast paths. Browser publishes its mic
# under <namespace>/<client_id>/<publish_track> and subscribes
# to the bot under <namespace>/<bot_id>/<subscribe_track>.
"client_id": args.moq_client_id,
"bot_id": args.moq_bot_id,
"publish_track": "user-audio",
"subscribe_track": "bot-audio",
"transcript_track": "transcript",
}
@app.post("/start")
async def start_moq_bot(request: Request):
"""Start a MOQ bot session."""
try:
request_data = await request.json()
logger.debug(f"Received MOQ start request: {request_data}")
except Exception:
request_data = {}
body = request_data.get("body", {})
namespace = request_data.get("namespace", args.moq_namespace)
bot_module = _get_bot_module()
ready_event = asyncio.Event()
runner_args = MOQRunnerArguments(
host=args.moq_host,
port=args.moq_port,
path=args.moq_path,
namespace=namespace,
participant_id=args.moq_bot_id,
peer_id=args.moq_client_id,
verify_ssl=not args.moq_insecure,
body=body,
ready_event=ready_event,
)
runner_args.cli_args = args
session_id = str(uuid.uuid4())
moq_sessions[session_id] = {
"namespace": namespace,
"host": args.moq_host,
"port": args.moq_port,
}
# Spawn the bot and wait until it signals it has finished the MOQ
# handshake, so the browser's SUBSCRIBE arrives at a publisher the
# relay already knows about.
asyncio.create_task(bot_module.bot(runner_args))
try:
await asyncio.wait_for(ready_event.wait(), timeout=15.0)
except asyncio.TimeoutError:
return JSONResponse(
status_code=504,
content={"error": "Bot did not connect to MOQ relay within 15s"},
)
return {
"sessionId": session_id,
"namespace": namespace,
"relay": f"{args.moq_host}:{args.moq_port}",
}
def _setup_telephony_routes(app: FastAPI, args: argparse.Namespace):
"""Set up telephony-specific routes."""
# XML response templates (Exotel doesn't use XML webhooks)
@@ -957,7 +1087,7 @@ def main(parser: argparse.ArgumentParser | None = None):
"-t",
"--transport",
type=str,
choices=["daily", "webrtc", *TELEPHONY_TRANSPORTS],
choices=["daily", "webrtc", "moq", *TELEPHONY_TRANSPORTS],
default="webrtc",
help="Transport type",
)
@@ -992,6 +1122,62 @@ def main(parser: argparse.ArgumentParser | None = None):
help="Ensure requried WhatsApp environment variables are present",
)
# MOQ-specific arguments
parser.add_argument(
"--moq-host",
type=str,
default="localhost",
help="MOQ relay host address (default: localhost)",
)
parser.add_argument(
"--moq-port",
type=int,
default=4080,
help="MOQ relay port (default: 4080)",
)
parser.add_argument(
"--moq-path",
type=str,
default="/moq",
help="MOQ endpoint path (default: /moq)",
)
parser.add_argument(
"--moq-namespace",
type=str,
default="pipecat",
help="MOQ namespace/room (default: pipecat)",
)
parser.add_argument(
"--moq-bot-id",
type=str,
default="bot0",
help="This bot's participant id; broadcasts under <namespace>/<bot-id> (default: bot0)",
)
parser.add_argument(
"--moq-client-id",
type=str,
default="client0",
help="Peer client's participant id the bot subscribes to (default: client0)",
)
parser.add_argument(
"--moq-insecure",
action="store_true",
default=False,
help="Disable SSL certificate verification for MOQ relay",
)
parser.add_argument(
"--moq-cert",
type=str,
default=None,
help="Path to relay TLS certificate (PEM) for WebTransport cert pinning",
)
parser.add_argument(
"--moq-web-port",
type=int,
default=None,
help="MOQ relay WebTransport port for browser clients (defaults to --moq-port)",
)
args = parser.parse_args()
# Validate and clean proxy hostname
@@ -1051,6 +1237,15 @@ def main(parser: argparse.ArgumentParser | None = None):
else:
print(f" → Open http://{args.host}:{args.port} in your browser to start a session")
print()
elif args.transport == "moq":
print()
print(f"🚀 Bot ready! (MOQ)")
print(f" → Connecting to MOQ relay at {args.moq_host}:{args.moq_port}")
print(f" → Namespace: {args.moq_namespace}")
print(f" → Status page: http://{args.host}:{args.port}")
print()
print(f" Connect a MOQ client to the same relay and namespace to start talking.")
print()
RUNNER_DOWNLOADS_FOLDER = args.folder
RUNNER_HOST = args.host

View File

@@ -11,8 +11,9 @@ information to bot functions.
"""
import argparse
import asyncio
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Optional
from fastapi import WebSocket
from pydantic import BaseModel
@@ -135,3 +136,29 @@ class LiveKitRunnerArguments(RunnerArguments):
room_name: str
url: str
token: str
@dataclass
class MOQRunnerArguments(RunnerArguments):
"""MOQ (Media over QUIC) transport session arguments for the runner.
Parameters:
host: MOQ relay server hostname.
port: MOQ relay server port.
path: MOQ endpoint path on the relay.
namespace: MOQ namespace (like a room identifier).
verify_ssl: Whether to verify SSL certificates.
ready_event: Optional event the bot sets once it has connected to the
relay and finished the MOQ handshake. Lets the HTTP `/start`
endpoint block until the bot is reachable before telling the
browser to open its WebTransport.
"""
host: str
port: int
path: str = "/moq"
namespace: str = "pipecat"
participant_id: str = "bot0"
peer_id: str = "client0"
verify_ssl: bool = True
ready_event: Optional[asyncio.Event] = field(default=None, kw_only=True)

View File

@@ -41,6 +41,7 @@ from loguru import logger
from pipecat.runner.types import (
DailyRunnerArguments,
LiveKitRunnerArguments,
MOQRunnerArguments,
SmallWebRTCRunnerArguments,
WebSocketRunnerArguments,
)
@@ -582,5 +583,25 @@ async def create_transport(
params=params,
)
elif isinstance(runner_args, MOQRunnerArguments):
params = _get_transport_params("moq", transport_params)
from pipecat.transports.moq.transport import MOQParams, MOQTransport
# Convert TransportParams to MOQParams if needed, applying runner args
if not isinstance(params, MOQParams):
params = MOQParams(**params.model_dump())
params.verify_ssl = runner_args.verify_ssl
params.namespace = runner_args.namespace
params.participant_id = runner_args.participant_id
params.peer_id = runner_args.peer_id
return MOQTransport(
params=params,
host=runner_args.host,
port=runner_args.port,
path=runner_args.path,
)
else:
raise ValueError(f"Unsupported runner arguments type: {type(runner_args)}")

View File

@@ -419,30 +419,30 @@ class CartesiaTTSService(WebsocketTTSService):
"""Convenience method to create a speed tag."""
return f'<speed ratio="{speed}" />'
def _is_cjk_language(self, language: str) -> bool:
"""Check if the given language is CJK (Chinese, Japanese, Korean).
def _is_chinese_or_japanese_language(self, language: str) -> bool:
"""Check if the given language is Chinese or Japanese.
Args:
language: The language code to check.
Returns:
True if the language is Chinese, Japanese, or Korean.
True if the language is Chinese or Japanese.
"""
cjk_languages = {"zh", "ja", "ko"}
base_lang = language.split("-")[0].lower()
return base_lang in cjk_languages
return base_lang in {"zh", "ja"}
def _process_word_timestamps_for_language(
self, words: list[str], starts: list[float]
) -> list[tuple[str, float]]:
"""Process word timestamps based on the current language.
For CJK languages, Cartesia groups related characters in the same timestamp message.
For Chinese and Japanese, Cartesia groups related characters in the same timestamp
message.
For example, in Japanese a single message might be `['', '', '', '', '', '']`.
We combine these into single words so the downstream aggregator can add natural
spacing between meaningful units rather than individual characters.
For non-CJK languages, words are already properly separated and are used as-is.
For other languages, words are already properly separated and are used as-is.
Args:
words: List of words/characters from Cartesia.
@@ -453,10 +453,10 @@ class CartesiaTTSService(WebsocketTTSService):
"""
current_language = assert_given(self._settings.language)
# Check if this is a CJK language (if language is None, treat as non-CJK)
if current_language and self._is_cjk_language(current_language):
# For CJK languages, combine all characters in this message into one word
# using the first character's start time
# Check if this is a Chinese/Japanese language (if language is None, treat as other)
if current_language and self._is_chinese_or_japanese_language(current_language):
# For Chinese/Japanese, combine all characters in this message into one word
# using the first character's start time.
if words and starts:
combined_word = "".join(words)
first_start = starts[0]
@@ -467,6 +467,11 @@ class CartesiaTTSService(WebsocketTTSService):
# For non-CJK languages, use as-is
return list(zip(words, starts))
def _word_timestamps_include_inter_frame_spaces(self) -> bool:
"""Whether timestamp text should be treated as carrying its own spacing."""
current_language = assert_given(self._settings.language)
return bool(current_language and self._is_chinese_or_japanese_language(current_language))
def _build_msg(
self,
text: str = "",
@@ -660,7 +665,13 @@ class CartesiaTTSService(WebsocketTTSService):
processed_timestamps = self._process_word_timestamps_for_language(
msg["word_timestamps"]["words"], msg["word_timestamps"]["start"]
)
await self.add_word_timestamps(processed_timestamps, ctx_id)
await self.add_word_timestamps(
processed_timestamps,
ctx_id,
includes_inter_frame_spaces=(
True if self._word_timestamps_include_inter_frame_spaces() else None
),
)
elif msg["type"] == "chunk":
frame = TTSAudioRawFrame(
audio=base64.b64decode(msg["data"]),

View File

@@ -150,7 +150,7 @@ class GradiumSTTService(WebsocketSTTService):
self,
*,
api_key: str,
api_endpoint_base_url: str = "wss://eu.api.gradium.ai/api/speech/asr",
api_endpoint_base_url: str = "wss://api.gradium.ai/api/speech/asr",
encoding: str = "pcm",
sample_rate: int | None = None,
params: InputParams | None = None,
@@ -163,7 +163,7 @@ class GradiumSTTService(WebsocketSTTService):
Args:
api_key: Gradium API key for authentication.
api_endpoint_base_url: WebSocket endpoint URL. Defaults to Gradium's streaming endpoint.
api_endpoint_base_url: WebSocket endpoint URL.
encoding: Base audio encoding type. One of "pcm", "wav", or "opus".
For PCM, the sample rate is appended automatically from the
pipeline's audio_in_sample_rate (e.g., "pcm" becomes "pcm_16000").

View File

@@ -68,7 +68,7 @@ class GradiumTTSService(WebsocketTTSService):
*,
api_key: str,
voice_id: str | None = None,
url: str = "wss://eu.api.gradium.ai/api/speech/tts",
url: str = "wss://api.gradium.ai/api/speech/tts",
model: str | None = None,
json_config: str | None = None,
params: InputParams | None = None,

View File

@@ -48,7 +48,8 @@ from pipecat.frames.frames import (
UserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators import async_tool_messages
from pipecat.processors.aggregators.llm_context import LLMContext, LLMSpecificMessage
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.llm_service import FunctionCallFromLLM, LLMService
from pipecat.services.settings import (
@@ -361,6 +362,7 @@ class InworldRealtimeLLMService(LLMService[InworldRealtimeLLMAdapter]):
self._messages_added_manually = {}
self._pending_function_calls = {}
self._completed_tool_calls = set()
self._async_tool_warning_logged: bool = False
self._register_event_handler("on_conversation_item_created")
self._register_event_handler("on_conversation_item_updated")
@@ -629,6 +631,7 @@ class InworldRealtimeLLMService(LLMService[InworldRealtimeLLMAdapter]):
self._receive_task = None
self._completed_tool_calls = set()
self._async_tool_warning_logged = False
self._audio_buffer = b""
self._interim_transcription_text = ""
self._disconnecting = False
@@ -1000,9 +1003,80 @@ class InworldRealtimeLLMService(LLMService[InworldRealtimeLLMAdapter]):
async def _process_completed_function_calls(self, send_new_results: bool):
"""Process completed function calls and send results to the service."""
# If the user registered a function with cancel_on_interruption=False,
# the aggregator emits async-tool-style messages into the context. As
# of this writing, Inworld Realtime doesn't appear to handle the
# resulting delayed tool result reliably — the routing code below
# is best-effort. Surface a one-time warning so users see they're not
# getting what they expect.
if not self._async_tool_warning_logged:
for message in self._context.get_messages():
if isinstance(message, LLMSpecificMessage):
continue
if async_tool_messages.parse_message(message) is not None:
logger.error(
f"{self}: cancel_on_interruption=False is not reliably "
f"supported by Inworld Realtime as of this writing. "
f"Use cancel_on_interruption=True (the default), or "
f"consider another LLM service if your tool needs the "
f"async semantics."
)
await self.push_error(
error_msg=(
"cancel_on_interruption=False is not reliably supported "
"by Inworld Realtime as of this writing."
),
)
self._async_tool_warning_logged = True
break
sent_new_result = False
for message in self._context.get_messages():
# LLMSpecificMessages are opaque provider-specific payloads, not
# standard tool-result messages — skip them.
if isinstance(message, LLMSpecificMessage):
continue
# Async-tool messages live alongside regular tool messages in the
# context; detect and route them before the regular logic so we
# don't try to send the async-tool envelope JSON as a tool result.
async_payload = async_tool_messages.parse_message(message)
if async_payload is not None:
if async_payload.tool_call_id in self._completed_tool_calls:
continue
if async_payload.kind == "started":
# The provider already issued the tool call and natively
# awaits a result; nothing to send for the started marker.
continue
if async_payload.kind == "intermediate":
logger.error(
f"{self}: Inworld Realtime does not support streamed async "
f"tool results; dropping intermediate result for "
f"tool_call_id={async_payload.tool_call_id}. Consider "
f"another LLM service if your tool needs to stream "
f"intermediate results."
)
await self.push_error(
error_msg="Inworld Realtime does not support streamed async tool results.",
)
continue
if async_payload.kind == "final":
# Deliver via the formal tool-result channel — same path
# as a synchronous tool result, just delayed.
if send_new_results:
sent_new_result = True
await self._send_tool_result(
async_payload.tool_call_id, async_payload.result
)
self._completed_tool_calls.add(async_payload.tool_call_id)
continue
# Defensive: any async-tool message must not fall through
# to the regular tool-result block below, even if it
# carries a kind we don't recognize.
continue
# Look for newly-completed "regular" (as opposed to async-tool) results
if message.get("role") == "tool" and message.get("content") != "IN_PROGRESS":
tool_call_id = message.get("tool_call_id")
if tool_call_id and tool_call_id not in self._completed_tool_calls:
@@ -1011,6 +1085,8 @@ class InworldRealtimeLLMService(LLMService[InworldRealtimeLLMAdapter]):
await self._send_tool_result(tool_call_id, message.get("content"))
self._completed_tool_calls.add(tool_call_id)
# If we reported any new tool call results to the service, trigger
# another response
if sent_new_result:
await self._create_response()

View File

@@ -60,9 +60,40 @@ from pipecat.frames.frames import (
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.tts_service import TextAggregationMode, TTSService, WebsocketTTSService
from pipecat.transcriptions.language import Language, resolve_language
from pipecat.utils.tracing.service_decorators import traced_tts
def language_to_inworld_language(language: Language) -> str:
"""Convert a Language enum to an Inworld TTS BCP-47 language tag.
Args:
language: The Language enum value to convert.
Returns:
The corresponding Inworld BCP-47 language tag (e.g. ``"en-US"``).
Unverified languages fall back to their BCP-47 string value with a warning.
"""
LANGUAGE_MAP = {
Language.AR: "ar-SA",
Language.DE: "de-DE",
Language.EN: "en-US",
Language.ES: "es-ES",
Language.FR: "fr-FR",
Language.HE: "he-IL",
Language.HI: "hi-IN",
Language.IT: "it-IT",
Language.JA: "ja-JP",
Language.KO: "ko-KR",
Language.NL: "nl-NL",
Language.PL: "pl-PL",
Language.PT: "pt-BR",
Language.RU: "ru-RU",
Language.ZH: "zh-CN",
}
return resolve_language(language, LANGUAGE_MAP, use_base_code=False)
@dataclass
class InworldTTSSettings(TTSSettings):
"""Settings for InworldTTSService and InworldHttpTTSService.
@@ -70,10 +101,18 @@ class InworldTTSSettings(TTSSettings):
Parameters:
speaking_rate: Speaking rate for speech synthesis.
temperature: Temperature for speech synthesis.
delivery_mode: Controls the stability vs. creativity tradeoff.
``"STABLE"`` produces reliable, predictable speech.
``"BALANCED"`` is the default midpoint.
``"CREATIVE"`` produces more expressive, emotionally varied speech.
Only supported by ``inworld-tts-2``.
"""
speaking_rate: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
temperature: float | None | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
delivery_mode: Literal["STABLE", "BALANCED", "CREATIVE"] | None | _NotGiven = field(
default_factory=lambda: NOT_GIVEN
)
_aliases: ClassVar[dict[str, str]] = {
"voiceId": "voice",
@@ -167,6 +206,7 @@ class InworldHttpTTSService(TTSService):
language=None,
speaking_rate=None,
temperature=None,
delivery_mode=None,
)
# 2. Apply direct init arg overrides (deprecated)
@@ -227,6 +267,17 @@ class InworldHttpTTSService(TTSService):
"""
return True
def language_to_service_language(self, language: Language) -> str | None:
"""Convert a Language enum to Inworld language format.
Args:
language: The language to convert.
Returns:
The Inworld-specific BCP-47 language code, or None if not supported.
"""
return language_to_inworld_language(language)
async def start(self, frame: StartFrame):
"""Start the Inworld TTS service.
@@ -313,6 +364,10 @@ class InworldHttpTTSService(TTSService):
if self._settings.temperature is not None:
payload["temperature"] = self._settings.temperature
if self._settings.delivery_mode is not None:
payload["deliveryMode"] = self._settings.delivery_mode
if self._settings.language is not None:
payload["language"] = self._settings.language
# Use WORD timestamps for simplicity and correct spacing/capitalization
payload["timestampType"] = self._timestamp_type
@@ -609,6 +664,7 @@ class InworldTTSService(WebsocketTTSService):
language=None,
speaking_rate=None,
temperature=None,
delivery_mode=None,
)
# 2. Apply direct init arg overrides (deprecated)
@@ -700,6 +756,17 @@ class InworldTTSService(WebsocketTTSService):
"""
return True
def language_to_service_language(self, language: Language) -> str | None:
"""Convert a Language enum to Inworld language format.
Args:
language: The language to convert.
Returns:
The Inworld-specific BCP-47 language code, or None if not supported.
"""
return language_to_inworld_language(language)
async def start(self, frame: StartFrame):
"""Start the Inworld WebSocket TTS service.
@@ -1089,6 +1156,10 @@ class InworldTTSService(WebsocketTTSService):
if self._settings.temperature is not None:
create_config["temperature"] = self._settings.temperature
if self._settings.delivery_mode is not None:
create_config["deliveryMode"] = self._settings.delivery_mode
if self._settings.language is not None:
create_config["language"] = self._settings.language
if self._apply_text_normalization is not None:
create_config["applyTextNormalization"] = self._apply_text_normalization
if self._auto_mode is not None:

View File

@@ -280,6 +280,8 @@ class NvidiaSageMakerTTSService(InterruptibleTTSService):
self._client: SageMakerBidiClient | None = None
self._receive_task = None
self._speech_completed_event = asyncio.Event()
self._audio_buffer = b""
self._playback_started = False
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
@@ -377,7 +379,12 @@ class NvidiaSageMakerTTSService(InterruptibleTTSService):
logger.info(f"{self}: verifying if websocket connection is active {active}")
return active
def _reset_audio_buffer(self):
self._audio_buffer = b""
self._playback_started = False
async def _handle_interruption(self, frame: InterruptionFrame, direction: FrameDirection):
self._reset_audio_buffer()
if self._bot_speaking and self._client:
logger.debug(
f"{self}: interruption detected, sending input_text.done and waiting for speech.completed"
@@ -391,6 +398,30 @@ class NvidiaSageMakerTTSService(InterruptibleTTSService):
logger.warning(f"{self}: timed out waiting for conversation.item.speech.completed")
await super()._handle_interruption(frame, direction)
async def _handle_audio_chunk(self, audio: bytes, context_id: str | None = None):
"""Buffer audio and emit frames using a jitter-buffer approach.
Holds back audio until chunk_size bytes have been accumulated (to avoid
glitches at the start of playback), then emits each subsequent chunk
immediately as it arrives.
"""
self._audio_buffer += audio
if not self._playback_started:
if len(self._audio_buffer) < self.chunk_size:
return
self._playback_started = True
await self.push_frame(
TTSAudioRawFrame(
audio=self._audio_buffer,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
)
self._audio_buffer = b""
async def _receive_messages(self):
"""Receive NIM JSON events and push audio frames."""
while self._client and self._client.is_active and not self._disconnecting:
@@ -415,14 +446,7 @@ class NvidiaSageMakerTTSService(InterruptibleTTSService):
msg = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
# Unexpected binary frame — treat as raw PCM
await self.push_frame(
TTSAudioRawFrame(
audio=payload,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
)
await self._handle_audio_chunk(payload, context_id)
continue
event_type = msg.get("type", "")
@@ -434,14 +458,7 @@ class NvidiaSageMakerTTSService(InterruptibleTTSService):
chunk_b64 = msg.get("audio", "")
if chunk_b64:
await self.stop_ttfb_metrics()
await self.push_frame(
TTSAudioRawFrame(
audio=base64.b64decode(chunk_b64),
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
)
await self._handle_audio_chunk(base64.b64decode(chunk_b64), context_id)
elif event_type == "error":
await self.push_error(error_msg=f"NIM error: {msg.get('message', msg)}")
# In case of error we need to reconnect, otherwise we are not going to receive audio from the TTS service anymore

View File

@@ -8,6 +8,7 @@
import json
import time
from collections import Counter
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Any
@@ -201,6 +202,24 @@ def _prepare_language_hints(
return list(set(prepared_languages))
def _language_from_tokens(tokens: list[dict]) -> Language | None:
language_counts: Counter[Language] = Counter()
for token in tokens:
language = token.get("language")
if not language:
continue
try:
language_counts[Language(language)] += 1
except ValueError:
pass
if not language_counts:
return None
return language_counts.most_common(1)[0][0]
@dataclass
class SonioxSTTSettings(STTSettings):
"""Settings for SonioxSTTService.
@@ -557,6 +576,7 @@ class SonioxSTTService(WebsocketSTTService):
async def send_endpoint_transcript():
if self._final_transcription_buffer:
text = "".join(map(lambda token: token["text"], self._final_transcription_buffer))
language = _language_from_tokens(self._final_transcription_buffer)
# Soniox only pushes TranscriptionFrame when an end token is received,
# so every TranscriptionFrame is inherently finalized
await self.push_frame(
@@ -564,11 +584,12 @@ class SonioxSTTService(WebsocketSTTService):
text=text,
user_id=self._user_id,
timestamp=time_now_iso8601(),
language=language,
result=self._final_transcription_buffer,
finalized=True,
)
)
await self._handle_transcription(text, is_final=True)
await self._handle_transcription(text, is_final=True, language=language)
await self.stop_processing_metrics()
self._final_transcription_buffer = []

View File

@@ -1283,10 +1283,17 @@ class TTSService(AIService):
def get_active_audio_context_id(self) -> str | None:
"""Get the active audio context ID.
Returns the playback cursor when set (during active playback), falling
back to the current turn's synthesis context_id. The fallback covers
the gap between contexts and the start of a turn before the playback
task has popped the just-created context off the serialization queue —
important for services whose wire protocol does not echo context_id
back on incoming audio.
Returns:
The active context ID, or None if no context is active.
The active context ID, or None if neither cursor is set.
"""
return self._playing_context_id
return self._playing_context_id or self._turn_context_id
async def remove_active_audio_context(self):
"""Remove the active audio context."""

View File

@@ -0,0 +1,26 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""MOQ (Media over QUIC) transport implementation for Pipecat.
This module provides MOQ transport functionality for real-time media streaming
using the QUIC protocol, supporting low-latency audio and video transmission
with pub/sub semantics.
"""
from pipecat.transports.moq.transport import (
MOQInputTransport,
MOQOutputTransport,
MOQParams,
MOQTransport,
)
__all__ = [
"MOQInputTransport",
"MOQOutputTransport",
"MOQParams",
"MOQTransport",
]

View File

@@ -0,0 +1,546 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""MOQ (Media over QUIC) protocol implementation — moq-lite-02.
This module implements the moq-lite-02 protocol layer for pub/sub media
streaming over QUIC. It provides message types, encoding/decoding utilities,
and session management for real-time media transmission.
moq-lite-02 uses a stream-per-request model: each operation (setup,
subscribe, announce) opens its own bidirectional QUIC stream, and media
data flows on unidirectional streams as GROUP + FRAME sequences.
Based on moq-lite-02 (version code 0xff0dad02).
"""
import struct
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# Protocol constants
# ---------------------------------------------------------------------------
MOQL_VERSION = 0xFF0DAD02 # moq-lite-02
MOQL_ALPN = "moql"
# Stream type bytes (written as first varint on bidi streams)
STREAM_TYPE_SESSION = 0
STREAM_TYPE_ANNOUNCE = 1
STREAM_TYPE_SUBSCRIBE = 2
# Setup message type bytes (u8)
CLIENT_SETUP_TYPE = 0x20
SERVER_SETUP_TYPE = 0x21
# Unidirectional stream type (u8)
UNI_STREAM_TYPE_GROUP = 0
# Announce update status
ANNOUNCE_STATUS_ACTIVE = 0
ANNOUNCE_STATUS_ENDED = 1
class MOQRole(IntEnum):
"""MOQ endpoint roles."""
PUBLISHER = 0x01
SUBSCRIBER = 0x02
PUBSUB = 0x03
class MOQTrackType(IntEnum):
"""Types of media tracks in MOQ."""
AUDIO = 0x01
VIDEO = 0x02
DATA = 0x03
@dataclass
class MOQTrack:
"""Represents a MOQ media track.
Parameters:
broadcast_path: The broadcast path (e.g., "pipecat").
name: The track name (e.g., "audio" or "video").
track_type: The type of media track.
priority: Track priority (lower is higher priority).
"""
broadcast_path: str
name: str
track_type: MOQTrackType = MOQTrackType.DATA
priority: int = 128
@property
def full_name(self) -> str:
"""Get the full track identifier."""
return f"{self.broadcast_path}/{self.name}"
class MOQCodec:
"""Encoder/decoder for MOQ wire protocol messages (moq-lite-02)."""
@staticmethod
def encode_varint(value: int) -> bytes:
"""Encode an integer as a QUIC variable-length integer.
Args:
value: The integer to encode.
Returns:
The encoded bytes.
"""
if value < 0x40:
return struct.pack("!B", value)
elif value < 0x4000:
return struct.pack("!H", value | 0x4000)
elif value < 0x40000000:
return struct.pack("!I", value | 0x80000000)
else:
return struct.pack("!Q", value | 0xC000000000000000)
@staticmethod
def decode_varint(data: bytes, offset: int = 0) -> Tuple[int, int]:
"""Decode a QUIC variable-length integer.
Args:
data: The bytes to decode from.
offset: Starting offset in the data.
Returns:
Tuple of (decoded value, new offset).
"""
first_byte = data[offset]
length_bits = first_byte >> 6
if length_bits == 0:
return first_byte, offset + 1
elif length_bits == 1:
value = struct.unpack("!H", data[offset : offset + 2])[0] & 0x3FFF
return value, offset + 2
elif length_bits == 2:
value = struct.unpack("!I", data[offset : offset + 4])[0] & 0x3FFFFFFF
return value, offset + 4
else:
value = struct.unpack("!Q", data[offset : offset + 8])[0] & 0x3FFFFFFFFFFFFFFF
return value, offset + 8
@staticmethod
def encode_string(value: str) -> bytes:
"""Encode a string with varint length prefix.
Args:
value: The string to encode.
Returns:
The encoded bytes.
"""
encoded = value.encode("utf-8")
return MOQCodec.encode_varint(len(encoded)) + encoded
@staticmethod
def decode_string(data: bytes, offset: int = 0) -> Tuple[str, int]:
"""Decode a length-prefixed string.
Args:
data: The bytes to decode from.
offset: Starting offset in the data.
Returns:
Tuple of (decoded string, new offset).
"""
length, offset = MOQCodec.decode_varint(data, offset)
value = data[offset : offset + length].decode("utf-8")
return value, offset + length
# ------------------------------------------------------------------
# Setup messages (on a dedicated bidi stream with stream_type=0)
# ------------------------------------------------------------------
@staticmethod
def encode_client_setup(
role: MOQRole,
supported_versions: List[int],
path: Optional[str] = None,
) -> bytes:
"""Encode a CLIENT_SETUP message for raw QUIC (ALPN "moql").
Format: u8(0x20) + varint(body_len) + body
Body: varint(num_versions) + versions... + varint(num_params) + params...
Args:
role: The role this client is taking.
supported_versions: List of supported MOQ versions.
path: Optional connection path.
Returns:
The encoded message (including stream type).
"""
body = MOQCodec.encode_varint(len(supported_versions))
for version in supported_versions:
body += MOQCodec.encode_varint(version)
# Parameters
num_params = 1 # role
if path:
num_params += 1
body += MOQCodec.encode_varint(num_params)
# Role parameter (key=0)
body += MOQCodec.encode_varint(0) # Parameter key
body += MOQCodec.encode_varint(1) # Parameter length
body += MOQCodec.encode_varint(role)
# Path parameter (key=1) if provided
if path:
body += MOQCodec.encode_varint(1) # Parameter key
path_bytes = path.encode("utf-8")
body += MOQCodec.encode_varint(len(path_bytes))
body += path_bytes
# Frame: u8(0x20) + u16(body_len) + body
# The relay uses Draft14 wire encoding for ALPN "moql",
# which expects a big-endian u16 for the body length.
msg = struct.pack("!B", CLIENT_SETUP_TYPE)
msg += struct.pack("!H", len(body))
msg += body
return msg
@staticmethod
def decode_server_setup(data: bytes, offset: int = 0) -> Tuple[int, int]:
"""Decode a SERVER_SETUP message.
Format: u8(0x21) + u16(body_len) + body
Body: varint(selected_version) + params...
The relay uses Draft14 wire encoding for ALPN "moql",
which uses big-endian u16 for the body length.
Args:
data: The bytes to decode from.
offset: Starting offset in the data.
Returns:
Tuple of (selected_version, new offset).
"""
# Skip type byte (0x21)
msg_type = data[offset]
offset += 1
assert msg_type == SERVER_SETUP_TYPE, f"Expected SERVER_SETUP (0x21), got {msg_type:#x}"
# Body length (u16 big-endian, Draft14 wire encoding)
body_len = struct.unpack("!H", data[offset : offset + 2])[0]
offset += 2
body_start = offset
# Selected version
version, offset = MOQCodec.decode_varint(data, offset)
# Skip remaining params
offset = body_start + body_len
return version, offset
# ------------------------------------------------------------------
# Subscribe messages (on a dedicated bidi stream with stream_type=2)
# ------------------------------------------------------------------
@staticmethod
def encode_subscribe(
subscribe_id: int,
broadcast_path: str,
track_name: str,
priority: int = 128,
) -> bytes:
"""Encode a SUBSCRIBE message body.
Body format: varint(sub_id) + string(broadcast_path) + string(track_name) + u8(priority)
The stream type varint(2) is written separately by the caller.
Args:
subscribe_id: Unique subscription identifier.
broadcast_path: Broadcast path (namespace).
track_name: Name of the track.
priority: Subscriber priority (u8).
Returns:
The encoded subscribe body with varint length prefix.
"""
body = MOQCodec.encode_varint(subscribe_id)
body += MOQCodec.encode_string(broadcast_path)
body += MOQCodec.encode_string(track_name)
body += struct.pack("!B", priority)
# Wrap: varint(body_len) + body
return MOQCodec.encode_varint(len(body)) + body
@staticmethod
def encode_subscribe_ok() -> bytes:
"""Encode a SUBSCRIBE_OK response.
Format: varint(0) — empty body.
Returns:
The encoded SUBSCRIBE_OK.
"""
return MOQCodec.encode_varint(0)
@staticmethod
def decode_subscribe(data: bytes, offset: int = 0) -> Tuple[int, str, str, int, int]:
"""Decode a SUBSCRIBE message body.
Args:
data: The bytes to decode from.
offset: Starting offset.
Returns:
Tuple of (subscribe_id, broadcast_path, track_name, priority, new offset).
"""
# Body length
body_len, offset = MOQCodec.decode_varint(data, offset)
body_end = offset + body_len
subscribe_id, offset = MOQCodec.decode_varint(data, offset)
broadcast_path, offset = MOQCodec.decode_string(data, offset)
track_name, offset = MOQCodec.decode_string(data, offset)
priority = data[offset]
offset += 1
return subscribe_id, broadcast_path, track_name, priority, body_end
@staticmethod
def decode_subscribe_ok(data: bytes, offset: int = 0) -> int:
"""Decode a SUBSCRIBE_OK response.
Format: varint(body_len) where body_len should be 0.
Args:
data: The bytes to decode from.
offset: Starting offset.
Returns:
New offset after decoding.
"""
body_len, offset = MOQCodec.decode_varint(data, offset)
return offset + body_len
# ------------------------------------------------------------------
# Announce messages (on a dedicated bidi stream with stream_type=1)
# ------------------------------------------------------------------
@staticmethod
def encode_announce_please(path_prefix: str) -> bytes:
"""Encode an ANNOUNCE_PLEASE message body.
Body format: string(path_prefix)
Framed as: varint(1) + varint(body_len) + body
The stream type varint(1) is written separately by the caller.
Args:
path_prefix: The path prefix to request announcements for.
Returns:
The encoded announce_please body with varint length prefix.
"""
body = MOQCodec.encode_string(path_prefix)
return MOQCodec.encode_varint(len(body)) + body
@staticmethod
def encode_announce_init(suffixes: List[str]) -> bytes:
"""Encode an ANNOUNCE_INIT response.
Body format: varint(count) + string(suffix)...
Framed as: varint(body_len) + body
Args:
suffixes: List of path suffixes to announce.
Returns:
The encoded announce_init.
"""
body = MOQCodec.encode_varint(len(suffixes))
for suffix in suffixes:
body += MOQCodec.encode_string(suffix)
return MOQCodec.encode_varint(len(body)) + body
@staticmethod
def encode_announce_update(status: int, path_suffix: str) -> bytes:
"""Encode an ANNOUNCE_UPDATE message.
Body format: u8(status) + string(path_suffix)
Framed as: varint(body_len) + body
Args:
status: Announce status (0=active, 1=ended).
path_suffix: The path suffix being updated.
Returns:
The encoded announce_update.
"""
body = struct.pack("!B", status)
body += MOQCodec.encode_string(path_suffix)
return MOQCodec.encode_varint(len(body)) + body
@staticmethod
def decode_announce_please(data: bytes, offset: int = 0) -> Tuple[str, int]:
"""Decode an ANNOUNCE_PLEASE message body.
Args:
data: The bytes to decode from.
offset: Starting offset.
Returns:
Tuple of (path_prefix, new offset).
"""
body_len, offset = MOQCodec.decode_varint(data, offset)
body_end = offset + body_len
path_prefix, offset = MOQCodec.decode_string(data, offset)
return path_prefix, body_end
# ------------------------------------------------------------------
# Data messages (on unidirectional streams)
# ------------------------------------------------------------------
@staticmethod
def encode_group_header(subscribe_id: int, group_sequence: int) -> bytes:
"""Encode a GROUP header for a unidirectional data stream.
Format: u8(0) + varint(body_len) + varint(subscribe_id) + varint(group_sequence)
Args:
subscribe_id: The subscription ID this data is for.
group_sequence: The group sequence number.
Returns:
The encoded GROUP header.
"""
body = MOQCodec.encode_varint(subscribe_id)
body += MOQCodec.encode_varint(group_sequence)
msg = struct.pack("!B", UNI_STREAM_TYPE_GROUP)
msg += MOQCodec.encode_varint(len(body))
msg += body
return msg
@staticmethod
def encode_frame(payload: bytes) -> bytes:
"""Encode a FRAME within a group.
Format: varint(payload_len) + payload
Args:
payload: The media payload data.
Returns:
The encoded frame.
"""
return MOQCodec.encode_varint(len(payload)) + payload
@staticmethod
def decode_group_header(data: bytes, offset: int = 0) -> Tuple[int, int, int]:
"""Decode a GROUP header from a unidirectional stream.
Format: u8(0) + varint(body_len) + varint(subscribe_id) + varint(group_sequence)
Args:
data: The bytes to decode from.
offset: Starting offset.
Returns:
Tuple of (subscribe_id, group_sequence, new offset after header).
"""
# Stream type byte (should be 0)
stream_type = data[offset]
offset += 1
# Body length
body_len, offset = MOQCodec.decode_varint(data, offset)
body_start = offset
subscribe_id, offset = MOQCodec.decode_varint(data, offset)
group_sequence, offset = MOQCodec.decode_varint(data, offset)
# Ensure offset aligns with body_start + body_len
offset = body_start + body_len
return subscribe_id, group_sequence, offset
@staticmethod
def decode_frames(data: bytes, offset: int = 0) -> List[bytes]:
"""Decode all FRAMEs from remaining data after GROUP header.
Format: varint(payload_len) + payload, repeated.
Args:
data: The bytes to decode from.
offset: Starting offset (after GROUP header).
Returns:
List of payload byte arrays.
"""
frames = []
while offset < len(data):
payload_len, offset = MOQCodec.decode_varint(data, offset)
payload = data[offset : offset + payload_len]
offset += payload_len
frames.append(bytes(payload))
return frames
class MOQSession:
"""Manages MOQ session state for moq-lite-02.
Tracks subscriptions and group sequences for a single MOQ connection.
In moq-lite-02, track aliases are gone — subscribe_id is used directly.
"""
def __init__(self, role: MOQRole = MOQRole.PUBSUB):
"""Initialize the MOQ session.
Args:
role: The role for this session (publisher, subscriber, or both).
"""
self.role = role
self.version: int = MOQL_VERSION
self.setup_complete: bool = False
# Subscription tracking
self._next_subscribe_id: int = 1
# Group sequencing per subscribe_id (for publishing)
self._group_sequences: Dict[int, int] = {}
def next_subscribe_id(self) -> int:
"""Get the next available subscribe ID.
Returns:
The next subscribe ID.
"""
sid = self._next_subscribe_id
self._next_subscribe_id += 1
return sid
def get_next_group_sequence(self, subscribe_id: int) -> int:
"""Get the next group sequence number for publishing.
Args:
subscribe_id: The subscription ID to get the sequence for.
Returns:
The next group sequence number.
"""
seq = self._group_sequences.get(subscribe_id, 0)
self._group_sequences[subscribe_id] = seq + 1
return seq

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@ rich information about service execution including configuration,
parameters, and performance metrics.
"""
import contextlib
import functools
import inspect
import json
@@ -24,7 +23,16 @@ if TYPE_CHECKING:
from opentelemetry import context as context_api
from opentelemetry import trace
from pipecat.frames.frames import (
MetricsFrame,
TranscriptionFrame,
TTSStoppedFrame,
UserStoppedSpeakingFrame,
VADUserStartedSpeakingFrame,
)
from pipecat.metrics.metrics import TTFBMetricsData
from pipecat.processors.aggregators.llm_context import NOT_GIVEN
from pipecat.processors.frame_processor import FrameDirection
from pipecat.utils.tracing.service_attributes import (
add_gemini_live_span_attributes,
add_llm_span_attributes,
@@ -175,6 +183,13 @@ def traced_tts(func: Callable | None = None, *, name: str | None = None) -> Call
- Character count and text content
- Performance metrics like TTFB
The span is scoped to the full synthesis operation, from
``create_audio_context`` until ``TTSStoppedFrame`` (or
``remove_audio_context`` as a safety net), so TTFB and any other
runtime-computed metrics land on the correct span even when audio
chunks are delivered after ``run_tts`` returns (e.g. WebSocket
streaming TTS services).
Works with both async functions and generators.
Args:
@@ -190,102 +205,224 @@ def traced_tts(func: Callable | None = None, *, name: str | None = None) -> Call
def decorator(f):
is_async_generator = inspect.isasyncgenfunction(f)
@contextlib.asynccontextmanager
async def tracing_context(self, text):
"""Async context manager for TTS tracing.
def end_tts_span(service, context_id, *, interrupted=False):
"""End the TTS span for ``context_id`` if still open. Idempotent."""
entry = service._tts_spans.pop(context_id, None)
if not entry:
return
try:
span = entry["span"]
if interrupted:
span.set_attribute("tts.interrupted", True)
span.end()
except Exception as e:
logging.warning(f"Error closing TTS span: {e}")
Args:
self: The TTS service instance.
text: The text being synthesized.
def install_audio_context_patches(service):
"""Install per-instance wrappers on the audio-context methods.
Yields:
The active span for the TTS operation.
The wrappers own the lifetime of the TTS span:
- ``create_audio_context``: opens the span and records
baseline attributes.
- ``append_to_audio_context``: ends the span on
``TTSStoppedFrame``.
- ``push_frame``: records ``metrics.ttfb`` from the
canonical ``TTFBMetricsData`` payload of any
``MetricsFrame`` pushed by ``stop_ttfb_metrics``. Reading
the value from the metrics event (instead of polling
``_metrics.ttfb`` when the first audio is queued) avoids
the ``ttfb`` property's in-progress fallback, which would
otherwise report an under-estimate whenever a context's
audio waits behind earlier queued audio before
``_handle_audio_context`` actually stops the TTFB
measurement.
- ``remove_audio_context``: ends any still-open span as a
safety net for error and cancellation paths.
- ``on_audio_context_completed``: ends the span on natural
completion. Needed because services that rely on the
base class to auto-push ``TTSStoppedFrame`` (via
``push_frame`` in ``_handle_audio_context``) bypass the
``append_to_audio_context`` hook entirely.
- ``reset_active_audio_context``: ends the currently
playing context's span if still open. Always called from
``_handle_interruption``, so this is the interruption
hook.
The patches check ``_tracing_enabled`` at invocation time,
so they are safe to install regardless of whether tracing
is enabled.
"""
# Check if tracing is enabled for this service instance
if not getattr(self, "_tracing_enabled", False):
yield None
if getattr(service, "__tts_tracing_patches_installed__", False):
return
service.__tts_tracing_patches_installed__ = True
service._tts_spans = {}
orig_create = service.create_audio_context
orig_append = service.append_to_audio_context
orig_remove = service.remove_audio_context
orig_completed = service.on_audio_context_completed
orig_reset_active = service.reset_active_audio_context
orig_push_frame = service.push_frame
async def traced_create_audio_context(context_id):
if getattr(service, "_tracing_enabled", False):
try:
parent = _get_turn_context(service) or _get_parent_service_context(service)
tracer = trace.get_tracer("pipecat")
span = tracer.start_span("tts", context=parent)
service._tts_spans[context_id] = {"span": span, "ttfb_recorded": False}
settings = getattr(service, "_settings", None)
add_tts_span_attributes(
span=span,
service_name=service.__class__.__name__,
model=_get_model_name(service),
voice_id=getattr(settings, "voice", "unknown"),
settings=settings,
operation_name="tts",
)
except Exception as e:
logging.warning(f"Error opening TTS span: {e}")
return await orig_create(context_id)
async def traced_append_to_audio_context(context_id, frame):
entry = service._tts_spans.get(context_id)
if entry and frame is not None:
try:
if isinstance(frame, TTSStoppedFrame):
entry["span"].end()
service._tts_spans.pop(context_id, None)
except Exception as e:
logging.warning(f"Error updating TTS span: {e}")
return await orig_append(context_id, frame)
async def traced_push_frame(frame, direction=FrameDirection.DOWNSTREAM):
await orig_push_frame(frame, direction)
if not getattr(service, "_tracing_enabled", False):
return
if not isinstance(frame, MetricsFrame):
return
try:
playing_id = getattr(service, "_playing_context_id", None)
if playing_id is None:
return
entry = service._tts_spans.get(playing_id)
if not entry or entry["ttfb_recorded"]:
return
for data in frame.data:
if isinstance(data, TTFBMetricsData):
entry["span"].set_attribute("metrics.ttfb", data.value)
entry["ttfb_recorded"] = True
break
except Exception as e:
logging.warning(f"Error recording TTS ttfb from MetricsFrame: {e}")
async def traced_remove_audio_context(context_id):
entry = service._tts_spans.pop(context_id, None)
if entry:
try:
entry["span"].end()
except Exception as e:
logging.warning(f"Error closing TTS span: {e}")
return await orig_remove(context_id)
async def traced_on_audio_context_completed(context_id):
end_tts_span(service, context_id)
return await orig_completed(context_id)
def traced_reset_active_audio_context():
playing_id = getattr(service, "_playing_context_id", None)
if playing_id is not None:
end_tts_span(service, playing_id, interrupted=True)
return orig_reset_active()
service.create_audio_context = traced_create_audio_context
service.append_to_audio_context = traced_append_to_audio_context
service.push_frame = traced_push_frame
service.remove_audio_context = traced_remove_audio_context
service.on_audio_context_completed = traced_on_audio_context_completed
service.reset_active_audio_context = traced_reset_active_audio_context
def patch_setup(owner):
"""Wrap ``owner.setup`` so audio-context patches install per-instance.
Idempotent: if a parent class has already been wrapped,
skip. The patches check ``_tracing_enabled`` at invocation
time, so wrapping is always safe.
"""
original_setup = owner.setup
if getattr(original_setup, "__tts_tracing_setup_wrapped__", False):
return
service_class_name = self.__class__.__name__
span_name = "tts"
@functools.wraps(original_setup)
async def patched_setup(self, setup):
await original_setup(self, setup)
install_audio_context_patches(self)
# Get parent context
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
setattr(patched_setup, "__tts_tracing_setup_wrapped__", True)
owner.setup = patched_setup
# Create span
tracer = trace.get_tracer("pipecat")
with tracer.start_as_current_span(span_name, context=parent_context) as span:
try:
settings = getattr(self, "_settings", None)
add_tts_span_attributes(
span=span,
service_name=service_class_name,
model=_get_model_name(self),
voice_id=getattr(settings, "voice", "unknown"),
text=text,
settings=settings,
character_count=len(text),
operation_name="tts",
cartesia_version=getattr(self, "_cartesia_version", None),
context_id=getattr(self, "_context_id", None),
)
def attach_run_tts_attributes(service, text, args, kwargs):
"""Attach text-specific attributes to the in-flight TTS span."""
if not getattr(service, "_tracing_enabled", False):
return
try:
context_id = args[0] if args else kwargs.get("context_id")
entry = getattr(service, "_tts_spans", {}).get(context_id)
if entry and text:
span = entry["span"]
span.set_attribute("text", text)
span.set_attribute("metrics.character_count", len(text))
except Exception as e:
logging.warning(f"Error attaching TTS text to span: {e}")
yield span
def make_run_tts_wrapper():
"""Build the wrapper around ``run_tts`` that adds per-call attributes.
except Exception as e:
logging.warning(f"Error in TTS tracing: {e}")
raise
finally:
# Update TTFB metric at the end
ttfb: float | None = getattr(getattr(self, "_metrics", None), "ttfb", None)
if ttfb is not None:
span.set_attribute("metrics.ttfb", ttfb)
Span lifetime is owned by the audio-context patches. This
wrapper only attaches the text and character count to the
span that was opened by ``create_audio_context`` just
before ``run_tts`` was invoked.
"""
if is_async_generator:
if is_async_generator:
@functools.wraps(f)
async def gen_wrapper(self, text, *args, **kwargs):
if not getattr(self, "_tracing_enabled", False):
async for item in f(self, text, *args, **kwargs):
yield item
return
fn_called = False
try:
async with tracing_context(self, text):
fn_called = True
async for item in f(self, text, *args, **kwargs):
yield item
except Exception as e:
if fn_called:
raise
logging.error(f"Error in TTS tracing (continuing without tracing): {e}")
@functools.wraps(f)
async def gen_wrapper(self, text, *args, **kwargs):
attach_run_tts_attributes(self, text, args, kwargs)
async for item in f(self, text, *args, **kwargs):
yield item
return gen_wrapper
else:
return gen_wrapper
@functools.wraps(f)
async def wrapper(self, text, *args, **kwargs):
if not getattr(self, "_tracing_enabled", False):
return await f(self, text, *args, **kwargs)
async def coro_wrapper(self, text, *args, **kwargs):
attach_run_tts_attributes(self, text, args, kwargs)
return await f(self, text, *args, **kwargs)
fn_called = False
try:
async with tracing_context(self, text):
fn_called = True
return await f(self, text, *args, **kwargs)
except Exception as e:
if fn_called:
raise
logging.error(f"Error in TTS tracing (continuing without tracing): {e}")
return await f(self, text, *args, **kwargs)
return coro_wrapper
return wrapper
class _TracedTTSDescriptor:
"""Class-level descriptor that wires up TTS tracing at class definition time.
``__set_name__`` fires when the class body finishes evaluating,
giving us a chance to wrap the owner's ``setup()`` so that the
audio-context patches install on every instance before any
``create_audio_context`` call (including the very first one).
"""
def __set_name__(self, owner, attr_name):
patch_setup(owner)
setattr(owner, attr_name, make_run_tts_wrapper())
return _TracedTTSDescriptor()
if func is not None:
return decorator(func)
# ``decorator(func)`` returns a descriptor placeholder that
# Python replaces with the real wrapped function once
# ``__set_name__`` runs at class definition time. Pyright sees
# only the descriptor instance, hence the ignore.
return decorator(func) # type: ignore[return-value]
return decorator
@@ -299,72 +436,285 @@ def traced_stt(func: Callable | None = None, *, name: str | None = None) -> Call
- Language information
- Performance metrics like TTFB
The span is scoped to one STT segment, from
``VADUserStartedSpeakingFrame`` (or the first ``TranscriptionFrame``
when VAD did not fire, e.g. whispered speech) until a finalized
``TranscriptionFrame``. Multiple finalized transcripts in a single
user turn produce multiple sequential spans, each anchored at the
point speech for that segment began. ``metrics.ttfb`` is read after
the base ``push_frame`` runs ``stop_ttfb_metrics`` for the
finalized frame, so the value is correct for the closing span.
Args:
func: The STT method to trace.
name: Custom span name. Defaults to function name.
Returns:
Wrapped method with STT-specific tracing.
The original method unchanged. The decorator's class-definition-
time work is to install a ``push_frame`` wrapper on the owning
class that owns the span lifetime.
"""
if not is_tracing_available():
return _noop_decorator if func is None else _noop_decorator(func)
def decorator(f):
@functools.wraps(f)
async def wrapper(self, transcript, is_final, language=None):
if not getattr(self, "_tracing_enabled", False):
return await f(self, transcript, is_final, language)
def patch_push_frame(owner):
"""Wrap ``owner.push_frame`` to drive the STT span lifecycle.
fn_called = False
try:
service_class_name = self.__class__.__name__
span_name = "stt"
Idempotent: if a parent class has already been wrapped, skip.
The wrapper checks ``_tracing_enabled`` at invocation time,
so it is safe to install regardless of whether tracing is
enabled.
"""
original_push_frame = owner.push_frame
if getattr(original_push_frame, "__stt_tracing_push_frame_wrapped__", False):
return
# Get the turn context first, then fall back to service context
parent_context = _get_turn_context(self) or _get_parent_service_context(self)
def update_transcript(state, new_text):
"""Append or extend the current segment in ``state['segments']``.
# Create a new span as child of the turn span or service span
If ``new_text`` starts with the last recorded segment,
treat it as a continuation (interim accumulation) and
replace the last segment. Otherwise treat it as a new
segment and append. Some STT services (Deepgram with
utterance_end_ms enabled, for example) emit several
``TranscriptionFrame``s per turn where each carries a
different segment rather than a cumulative update —
without this logic the span's transcript would only
show the last segment and the beginning would be lost.
"""
if not new_text:
return
segments = state["segments"]
if not segments:
segments.append(new_text)
elif new_text.startswith(segments[-1]):
segments[-1] = new_text
else:
segments.append(new_text)
def open_span(service, state):
"""Open the STT span, anchored at ``segment_start_time`` if set."""
parent = _get_turn_context(service) or _get_parent_service_context(service)
tracer = trace.get_tracer("pipecat")
with tracer.start_as_current_span(
span_name, context=parent_context
) as current_span:
start_time_ns = (
int(state["segment_start_time"] * 1e9)
if state["segment_start_time"] is not None
else None
)
span = tracer.start_span("stt", context=parent, start_time=start_time_ns)
try:
settings = getattr(service, "_settings", None)
add_stt_span_attributes(
span=span,
service_name=service.__class__.__name__,
model=_get_model_name(service),
settings=settings,
vad_enabled=getattr(service, "vad_enabled", False),
)
except Exception as e:
logging.warning(f"Error setting STT span baseline attributes: {e}")
state["span"] = span
def handle_pre_push(service, frame, state):
"""Record speech-start anchor; lazy-open span on first transcript.
Lazy-opening on ``TranscriptionFrame`` (rather than on
``VADUserStartedSpeakingFrame`` or
``UserStartedSpeakingFrame``) avoids racing with
``TurnTraceObserver._handle_turn_started``, which runs
in a background task fired by ``_call_event_handler``
(``base_object.py:232``) and may not have set the new
turn's context yet — that produces STT spans parented
to the previous turn. By the time STT actually emits
a transcript, the turn observer has run.
Opening happens in pre-push (rather than post-push) so
that the recursive ``push_frame`` that
``STTService.push_frame`` triggers for the
``MetricsFrame`` (via ``stop_ttfb_metrics`` at
``stt_service.py:465``) sees the span already open and
can attribute ``metrics.ttfb`` to it.
"""
if isinstance(frame, VADUserStartedSpeakingFrame):
# Anchor the next span at the moment speech began.
# Skip if we already have an anchor (intra-turn VAD
# re-trigger) or a span open.
if state["span"] is None and state["segment_start_time"] is None:
state["segment_start_time"] = frame.timestamp - frame.start_secs
elif isinstance(frame, TranscriptionFrame) and state["span"] is None:
open_span(service, state)
async def handle_post_push(service, frame, state):
"""Attach per-frame attrs; close on finalized; record TTFB from MetricsFrame.
``metrics.ttfb`` is read off the ``TTFBMetricsData``
payload of any ``MetricsFrame`` pushed by
``stop_ttfb_metrics`` — the canonical value the rest
of the system uses — rather than from
``_metrics.ttfb``, which has an in-progress fallback
branch (``frame_processor_metrics.py:48-62``) that
would return an under-estimate if read at the wrong
time.
One STT span per finalized transcript: the span opens
lazily on the first ``TranscriptionFrame`` (pre-push,
anchored at speech start via ``segment_start_time``)
and closes on ``finalized=True``. Multiple finalized
transcripts in a single turn produce multiple spans.
For services that never set ``frame.finalized=True``
(e.g. Deepgram, which only marks it via
``confirm_finalize()``), the span closes on
``UserStoppedSpeakingFrame``. To capture
``metrics.ttfb`` for those spans we force-stop any
pending TTFB measurement before closing — that pushes
a ``MetricsFrame``, our post-push attributes the
value, and ``patched_stop_ttfb_metrics`` closes the
span. The ``stt.incomplete=true`` flag is only set if
neither a finalized transcript nor a TTFB measurement
ever finalized for the span.
"""
if isinstance(frame, UserStoppedSpeakingFrame):
prev_span = state["span"]
if prev_span is None:
return
metrics = getattr(service, "_metrics", None)
if metrics is not None and getattr(metrics, "_start_ttfb_time", 0) > 0:
last_transcript_time = getattr(service, "_last_transcript_time", 0) or None
try:
await service.stop_ttfb_metrics(end_time=last_transcript_time)
except Exception as e:
logging.warning(f"Error force-stopping STT TTFB on user turn end: {e}")
# patched_stop_ttfb_metrics may have closed the span
# via the timeout path; re-check.
if state["span"] is None:
state["segments"] = []
return
state["span"].set_attribute("stt.incomplete", True)
state["span"].end()
state["span"] = None
state["segment_start_time"] = None
state["segments"] = []
elif isinstance(frame, MetricsFrame):
span = state["span"]
if span is None:
return
for data in frame.data:
if isinstance(data, TTFBMetricsData):
span.set_attribute("metrics.ttfb", data.value)
break
elif isinstance(frame, TranscriptionFrame):
span = state["span"]
if span is None:
return
if frame.text:
update_transcript(state, frame.text)
span.set_attribute("transcript", " ".join(state["segments"]).strip())
span.set_attribute("is_final", bool(frame.finalized))
if frame.language:
span.set_attribute("language", str(frame.language))
if frame.user_id:
span.set_attribute("user_id", frame.user_id)
if frame.finalized:
span.end()
state["span"] = None
state["segment_start_time"] = None
state["segments"] = []
@functools.wraps(original_push_frame)
async def patched_push_frame(self, frame, direction=FrameDirection.DOWNSTREAM):
state = getattr(self, "_stt_span_state", None)
if state is None:
state = {"span": None, "segment_start_time": None, "segments": []}
self._stt_span_state = state
if getattr(self, "_tracing_enabled", False):
try:
# Get TTFB metric if available
ttfb: float | None = getattr(getattr(self, "_metrics", None), "ttfb", None)
# Use settings from the service if available
settings = getattr(self, "_settings", None)
add_stt_span_attributes(
span=current_span,
service_name=service_class_name,
model=_get_model_name(self),
transcript=transcript,
is_final=is_final,
language=str(language) if language else None,
user_id=getattr(self, "_user_id", None),
vad_enabled=getattr(self, "vad_enabled", False),
settings=settings,
ttfb=ttfb,
)
# Call the original function
fn_called = True
return await f(self, transcript, is_final, language)
handle_pre_push(self, frame, state)
except Exception as e:
# Log any exception but don't disrupt the main flow
logging.warning(f"Error in STT transcription tracing: {e}")
raise
except Exception as e:
if fn_called:
raise
logging.error(f"Error in STT tracing (continuing without tracing): {e}")
return await f(self, transcript, is_final, language)
logging.warning(f"Error in STT pre-push tracing: {e}")
return wrapper
await original_push_frame(self, frame, direction)
if getattr(self, "_tracing_enabled", False):
try:
await handle_post_push(self, frame, state)
except Exception as e:
logging.warning(f"Error in STT post-push tracing: {e}")
setattr(patched_push_frame, "__stt_tracing_push_frame_wrapped__", True)
owner.push_frame = patched_push_frame
def patch_stop_ttfb_metrics(owner):
"""Wrap ``owner.stop_ttfb_metrics`` to close the span on the timeout path.
When ``stop_ttfb_metrics`` is invoked with ``end_time`` set,
that signals the TTFB-timeout handler firing
(`stt_service.py:566`), or our own force-stop from the
``UserStoppedSpeakingFrame`` handler. In either case we
anchor the span's end at ``end_time``
(= ``_last_transcript_time``) rather than at whenever the
coroutine resumed.
``metrics.ttfb`` attribution is not done here — the
``MetricsFrame`` that ``stop_ttfb_metrics`` pushes flows
through ``push_frame`` and gets recorded by
``handle_post_push``, which reads the canonical
``TTFBMetricsData.value`` rather than the in-progress
``_metrics.ttfb`` property.
"""
original_stop = owner.stop_ttfb_metrics
if getattr(original_stop, "__stt_tracing_stop_ttfb_wrapped__", False):
return
@functools.wraps(original_stop)
async def patched_stop(self, *, end_time=None):
await original_stop(self, end_time=end_time)
if end_time is None:
return
if not getattr(self, "_tracing_enabled", False):
return
state = getattr(self, "_stt_span_state", None)
if not state or state["span"] is None:
return
try:
span = state["span"]
span.end(end_time=int(end_time * 1e9))
state["span"] = None
state["segment_start_time"] = None
state["segments"] = []
except Exception as e:
logging.warning(f"Error in STT stop_ttfb_metrics tracing: {e}")
setattr(patched_stop, "__stt_tracing_stop_ttfb_wrapped__", True)
owner.stop_ttfb_metrics = patched_stop
class _TracedSTTDescriptor:
"""Class-level descriptor that wires up STT tracing at class definition time.
``__set_name__`` fires when the class body finishes evaluating,
giving us a chance to wrap the owner's ``push_frame`` so that
VAD, transcription, and finalization events drive the span
lifecycle, and to wrap ``stop_ttfb_metrics`` so the
TTFB-timeout path can attach metrics and close the span when
no finalized transcript ever arrives. The decorated method
itself runs unchanged.
"""
def __set_name__(self, owner, attr_name):
patch_push_frame(owner)
patch_stop_ttfb_metrics(owner)
setattr(owner, attr_name, f)
return _TracedSTTDescriptor()
if func is not None:
return decorator(func)
# ``decorator(func)`` returns a descriptor placeholder that
# Python replaces with the real wrapped function once
# ``__set_name__`` runs at class definition time. Pyright sees
# only the descriptor instance, hence the ignore.
return decorator(func) # type: ignore[return-value]
return decorator
@@ -549,10 +899,6 @@ def traced_llm(func: Callable | None = None, *, name: str | None = None) -> Call
fn_called = True
result = await f(self, context, *args, **kwargs)
# Add aggregated output after function completes, if available
if output_text:
current_span.set_attribute("output", output_text)
return result
finally:
@@ -565,6 +911,15 @@ def traced_llm(func: Callable | None = None, *, name: str | None = None) -> Call
):
self.start_llm_usage_metrics = original_start_llm_usage_metrics
# Attach whatever output text we accumulated so
# far. Doing this in finally captures partial
# output when ``f`` is cancelled or raises mid-
# stream (e.g. interruption during LLM
# generation), rather than only on clean
# completion.
if output_text:
current_span.set_attribute("output", output_text)
# Update TTFB metric
ttfb: float | None = getattr(getattr(self, "_metrics", None), "ttfb", None)
if ttfb is not None:

111
tests/test_cartesia_tts.py Normal file
View File

@@ -0,0 +1,111 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.settings import TTSSettings
from pipecat.utils.string import TextPartForConcatenation, concatenate_aggregated_text
def _service(language: str) -> CartesiaTTSService:
service = CartesiaTTSService.__new__(CartesiaTTSService)
service._settings = TTSSettings(language=language)
return service
def _process_word_timestamps(
words: list[str], starts: list[float], language: str
) -> list[tuple[str, float]]:
return _service(language)._process_word_timestamps_for_language(words, starts)
def _concatenate_processed_timestamps(
timestamp_groups: list[tuple[list[str], list[float]]], language: str
) -> str:
service = _service(language)
text_parts = []
for words, starts in timestamp_groups:
processed_timestamps = service._process_word_timestamps_for_language(words, starts)
includes_inter_frame_spaces = service._word_timestamps_include_inter_frame_spaces()
text_parts.extend(
TextPartForConcatenation(
word,
includes_inter_part_spaces=includes_inter_frame_spaces,
)
for word, _timestamp in processed_timestamps
)
return concatenate_aggregated_text(text_parts)
def test_cartesia_chinese_word_timestamps_join_without_spaces():
assert _process_word_timestamps(
words=["", "", ""],
starts=[0.0, 0.1, 0.2],
language="zh",
) == [("你好。", 0.0)]
def test_cartesia_japanese_word_timestamps_join_without_spaces():
assert _process_word_timestamps(
words=["", "", "", "", "", ""],
starts=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5],
language="ja",
) == [("こんにちは。", 0.0)]
def test_cartesia_korean_word_timestamps_preserve_words_and_timestamps():
assert _process_word_timestamps(
words=["안녕하세요", "반갑습니다"],
starts=[0.0, 0.2],
language="ko",
) == [("안녕하세요", 0.0), ("반갑습니다", 0.2)]
def test_cartesia_korean_word_timestamps_do_not_join_latin_and_hangul():
assert _process_word_timestamps(
words=["AI", "어시스턴트입니다."],
starts=[3.7026982, 4.1999383],
language="ko",
) == [("AI", 3.7026982), ("어시스턴트입니다.", 4.1999383)]
def test_cartesia_japanese_timestamp_groups_reassemble_without_spaces():
assert (
_concatenate_processed_timestamps(
[
(["", "", "", "", "", "", ""], [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]),
(["", "", "", "", ""], [1.0, 1.1, 1.2, 1.3, 1.4]),
],
language="ja",
)
== "こんにちは、私はあなたの"
)
def test_cartesia_chinese_timestamp_groups_reassemble_without_spaces():
assert (
_concatenate_processed_timestamps(
[
(["", "", "", "", ""], [0.1, 0.2, 0.3, 0.4, 0.5]),
(["", "", "", ""], [1.0, 1.1, 1.2, 1.3]),
],
language="zh",
)
== "你好,我是你的智能"
)
def test_cartesia_korean_timestamp_groups_reassemble_with_spaces():
assert (
_concatenate_processed_timestamps(
[
(["저는"], [1.6]),
(["여러분의"], [1.8]),
(["AI", "어시스턴트입니다."], [3.7, 4.2]),
],
language="ko",
)
== "저는 여러분의 AI 어시스턴트입니다."
)

View File

@@ -0,0 +1,31 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Tests for Inworld TTS language code mapping."""
from pipecat.services.inworld.tts import language_to_inworld_language
from pipecat.transcriptions.language import Language
def test_inworld_base_languages_resolve_to_canonical_regional_tags():
"""Base GA languages should use the regional tags emitted by Inworld Playground."""
assert language_to_inworld_language(Language.EN) == "en-US"
assert language_to_inworld_language(Language.RU) == "ru-RU"
assert language_to_inworld_language(Language.FR) == "fr-FR"
assert language_to_inworld_language(Language.ZH) == "zh-CN"
def test_inworld_regional_languages_are_preserved():
"""Explicit regional variants should be passed through as supported BCP-47 tags."""
assert language_to_inworld_language(Language.EN_GB) == "en-GB"
assert language_to_inworld_language(Language.PT_PT) == "pt-PT"
assert language_to_inworld_language(Language.RU_RU) == "ru-RU"
def test_inworld_other_languages_are_passed_through_as_bcp47_tags():
"""Languages outside the canonical locale map should keep their BCP-47 enum value."""
assert language_to_inworld_language(Language.SV_SE) == "sv-SE"
assert language_to_inworld_language(Language.UK_UA) == "uk-UA"

175
tests/test_soniox_stt.py Normal file
View File

@@ -0,0 +1,175 @@
#
# Copyright (c) 2024-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import json
import pytest
from pipecat.frames.frames import TranscriptionFrame
from pipecat.services.soniox.stt import END_TOKEN, SonioxSTTService, _language_from_tokens
from pipecat.transcriptions.language import Language
class _FakeWebsocket:
def __init__(self, messages):
self._messages = messages
def __aiter__(self):
return self._iter_messages()
async def _iter_messages(self):
for message in self._messages:
yield message
def test_language_from_tokens_uses_single_recognized_language():
tokens = [
{"text": "Hello", "language": "en"},
{"text": " world", "language": "en"},
]
assert _language_from_tokens(tokens) == Language.EN
def test_language_from_tokens_uses_most_common_language():
tokens = [
{"text": "Ik", "language": "nl"},
{"text": " zoek", "language": "nl"},
{"text": " computer", "language": "en"},
]
assert _language_from_tokens(tokens) == Language.NL
def test_language_from_tokens_skips_unknown_language():
tokens = [
{"text": "Hello", "language": "en"},
{"text": "!", "language": "klingon"},
]
assert _language_from_tokens(tokens) == Language.EN
def test_language_from_tokens_skips_missing_language():
tokens = [
{"text": "Hello", "language": "en"},
{"text": " wereld"},
]
assert _language_from_tokens(tokens) == Language.EN
def test_language_from_tokens_ignores_unknown_and_missing_languages():
tokens = [
{"text": "Hello", "language": "klingon"},
{"text": " world"},
{"text": "!"},
]
assert _language_from_tokens(tokens) is None
def test_language_from_tokens_uses_first_language_on_tie():
tokens = [
{"text": "Hello", "language": "en"},
{"text": " wereld", "language": "nl"},
]
assert _language_from_tokens(tokens) == Language.EN
@pytest.mark.asyncio
async def test_receive_messages_sets_final_transcription_language(monkeypatch):
service = SonioxSTTService(api_key="test-key")
pushed_frames = []
traced_transcriptions = []
async def fake_push_frame(frame):
pushed_frames.append(frame)
async def fake_handle_transcription(transcript, is_final, language=None):
traced_transcriptions.append((transcript, is_final, language))
async def fake_stop_processing_metrics():
pass
messages = [
json.dumps(
{
"tokens": [
{"text": "Ik", "is_final": True, "language": "nl"},
{"text": " zoek", "is_final": True, "language": "nl"},
{"text": " computer", "is_final": True, "language": "en"},
{"text": END_TOKEN, "is_final": True},
]
}
),
json.dumps({"tokens": [], "finished": True}),
]
service._websocket = _FakeWebsocket(messages)
monkeypatch.setattr(service, "push_frame", fake_push_frame)
monkeypatch.setattr(service, "_handle_transcription", fake_handle_transcription)
monkeypatch.setattr(service, "stop_processing_metrics", fake_stop_processing_metrics)
await service._receive_messages()
final_frames = [frame for frame in pushed_frames if isinstance(frame, TranscriptionFrame)]
assert len(final_frames) == 1
assert final_frames[0].text == "Ik zoek computer"
assert final_frames[0].language == Language.NL
assert final_frames[0].finalized is True
assert final_frames[0].result == [
{"text": "Ik", "is_final": True, "language": "nl"},
{"text": " zoek", "is_final": True, "language": "nl"},
{"text": " computer", "is_final": True, "language": "en"},
]
assert traced_transcriptions == [("Ik zoek computer", True, Language.NL)]
@pytest.mark.asyncio
async def test_receive_messages_allows_final_transcription_without_language(monkeypatch):
service = SonioxSTTService(api_key="test-key")
pushed_frames = []
traced_transcriptions = []
async def fake_push_frame(frame):
pushed_frames.append(frame)
async def fake_handle_transcription(transcript, is_final, language=None):
traced_transcriptions.append((transcript, is_final, language))
async def fake_stop_processing_metrics():
pass
messages = [
json.dumps(
{
"tokens": [
{"text": "Tell", "is_final": True},
{"text": " me", "is_final": True},
{"text": " a", "is_final": True},
{"text": " joke.", "is_final": True},
{"text": END_TOKEN, "is_final": True},
]
}
),
json.dumps({"tokens": [], "finished": True}),
]
service._websocket = _FakeWebsocket(messages)
monkeypatch.setattr(service, "push_frame", fake_push_frame)
monkeypatch.setattr(service, "_handle_transcription", fake_handle_transcription)
monkeypatch.setattr(service, "stop_processing_metrics", fake_stop_processing_metrics)
await service._receive_messages()
final_frames = [frame for frame in pushed_frames if isinstance(frame, TranscriptionFrame)]
assert len(final_frames) == 1
assert final_frames[0].text == "Tell me a joke."
assert final_frames[0].language is None
assert final_frames[0].finalized is True
assert traced_transcriptions == [("Tell me a joke.", True, None)]

1703
uv.lock generated

File diff suppressed because it is too large Load Diff