From 21d8d148b8adebfaaf5986fc7058a85aa4b8b4ec Mon Sep 17 00:00:00 2001
From: Pyae Sone Myo <66440980+Rickaym@users.noreply.github.com>
Date: Sun, 12 Oct 2025 22:10:11 +0630
Subject: [PATCH 01/23] fix: handle partial words across alignment chunks
gracefully
---
src/pipecat/services/elevenlabs/tts.py | 60 ++++++++++++++++----------
1 file changed, 37 insertions(+), 23 deletions(-)
diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py
index 3d6d5bd4c..4c91c4f38 100644
--- a/src/pipecat/services/elevenlabs/tts.py
+++ b/src/pipecat/services/elevenlabs/tts.py
@@ -168,16 +168,24 @@ def build_elevenlabs_voice_settings(
def calculate_word_times(
- alignment_info: Mapping[str, Any], cumulative_time: float
-) -> List[Tuple[str, float]]:
+ alignment_info: Mapping[str, Any],
+ cumulative_time: float,
+ partial_word: str = "",
+ partial_word_start_time: float = 0.0,
+) -> tuple[List[Tuple[str, float]], str, float]:
"""Calculate word timestamps from character alignment information.
Args:
alignment_info: Character alignment data from ElevenLabs API.
cumulative_time: Base time offset for this chunk.
+ partial_word: Partial word carried over from previous chunk.
+ partial_word_start_time: Start time of the partial word.
Returns:
- List of (word, timestamp) tuples.
+ Tuple of (word_times, new_partial_word, new_partial_word_start_time):
+ - word_times: List of (word, timestamp) tuples for complete words
+ - new_partial_word: Incomplete word at end of chunk (empty if chunk ends with space)
+ - new_partial_word_start_time: Start time of the incomplete word
"""
chars = alignment_info["chars"]
char_start_times_ms = alignment_info["charStartTimesMs"]
@@ -186,41 +194,37 @@ def calculate_word_times(
logger.error(
f"calculate_word_times: length mismatch - chars={len(chars)}, times={len(char_start_times_ms)}"
)
- return []
+ return ([], partial_word, partial_word_start_time)
# Build words and track their start positions
words = []
- word_start_indices = []
- current_word = ""
- word_start_index = None
+ word_start_times = []
+ current_word = partial_word # Start with any partial word from previous chunk
+ word_start_time = partial_word_start_time if partial_word else None
for i, char in enumerate(chars):
if char == " ":
# End of current word
if current_word: # Only add non-empty words
words.append(current_word)
- word_start_indices.append(word_start_index)
+ word_start_times.append(word_start_time)
current_word = ""
- word_start_index = None
+ word_start_time = None
else:
# Building a word
- if word_start_index is None: # First character of new word
- word_start_index = i
+ if word_start_time is None: # First character of new word
+ # Convert from milliseconds to seconds and add cumulative offset
+ word_start_time = cumulative_time + (char_start_times_ms[i] / 1000.0)
current_word += char
- # Handle the last word if there's no trailing space
- if current_word and word_start_index is not None:
- words.append(current_word)
- word_start_indices.append(word_start_index)
+ # Build result for complete words
+ word_times = list(zip(words, word_start_times))
- # Calculate timestamps for each word
- word_times = []
- for word, start_idx in zip(words, word_start_indices):
- # Convert from milliseconds to seconds and add cumulative offset
- start_time_seconds = cumulative_time + (char_start_times_ms[start_idx] / 1000.0)
- word_times.append((word, start_time_seconds))
+ # Return any incomplete word at the end of this chunk
+ new_partial_word = current_word if current_word else ""
+ new_partial_word_start_time = word_start_time if word_start_time is not None else 0.0
- return word_times
+ return (word_times, new_partial_word, new_partial_word_start_time)
class ElevenLabsTTSService(AudioContextWordTTSService):
@@ -332,6 +336,9 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
# there's an interruption or TTSStoppedFrame.
self._started = False
self._cumulative_time = 0
+ # Track partial words that span across alignment chunks
+ self._partial_word = ""
+ self._partial_word_start_time = 0.0
# Context management for v1 multi API
self._context_id = None
@@ -609,7 +616,12 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
if msg.get("alignment"):
alignment = msg["alignment"]
- word_times = calculate_word_times(alignment, self._cumulative_time)
+ word_times, self._partial_word, self._partial_word_start_time = calculate_word_times(
+ alignment,
+ self._cumulative_time,
+ self._partial_word,
+ self._partial_word_start_time,
+ )
if word_times:
await self.add_word_timestamps(word_times)
@@ -683,6 +695,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
yield TTSStartedFrame()
self._started = True
self._cumulative_time = 0
+ self._partial_word = ""
+ self._partial_word_start_time = 0.0
# If a context ID does not exist, create a new one and
# register it. If an ID exists, that means the Pipeline is
# configured for allow_interruptions=False, so continue
From d9580f72a92601fd09e9b4a8f9b9e507a5b40f4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?=
Date: Mon, 13 Oct 2025 18:29:19 -0700
Subject: [PATCH 02/23] runner: allow subdirectories in --folder
---
CHANGELOG.md | 5 +++++
src/pipecat/runner/run.py | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 797ca06ab..424d6a420 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- The runner `--folder` argument now supports downloading files from
+ subdirectories.
+
### Fixed
- Fixed an issue where `RimeHttpTTSService` and `PiperTTSService` could generate
diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py
index fd1a8a952..53b39f4e3 100644
--- a/src/pipecat/runner/run.py
+++ b/src/pipecat/runner/run.py
@@ -217,7 +217,7 @@ def _setup_webrtc_routes(
"""Redirect root requests to client interface."""
return RedirectResponse(url="/client/")
- @app.get("/files/{filename}")
+ @app.get("/files/{filename:path}")
async def download_file(filename: str):
"""Handle file downloads."""
if not folder:
From 3b751322d3d18832b4fca299624eae710c7ed2af Mon Sep 17 00:00:00 2001
From: Pyae Sone Myo <66440980+Rickaym@users.noreply.github.com>
Date: Tue, 14 Oct 2025 23:04:09 +0630
Subject: [PATCH 03/23] fix: add interruption reset for partial word states
---
src/pipecat/services/elevenlabs/tts.py | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py
index 4c91c4f38..9e4154e4d 100644
--- a/src/pipecat/services/elevenlabs/tts.py
+++ b/src/pipecat/services/elevenlabs/tts.py
@@ -577,6 +577,8 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
logger.error(f"Error closing context on interruption: {e}")
self._context_id = None
self._started = False
+ self._partial_word = ""
+ self._partial_word_start_time = 0.0
async def _receive_messages(self):
"""Handle incoming WebSocket messages from ElevenLabs."""
@@ -616,11 +618,13 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
if msg.get("alignment"):
alignment = msg["alignment"]
- word_times, self._partial_word, self._partial_word_start_time = calculate_word_times(
- alignment,
- self._cumulative_time,
- self._partial_word,
- self._partial_word_start_time,
+ word_times, self._partial_word, self._partial_word_start_time = (
+ calculate_word_times(
+ alignment,
+ self._cumulative_time,
+ self._partial_word,
+ self._partial_word_start_time,
+ )
)
if word_times:
From b6b09975534b9f645b35a809f602aa6f1358a15b Mon Sep 17 00:00:00 2001
From: Pyae Sone Myo <66440980+Rickaym@users.noreply.github.com>
Date: Tue, 14 Oct 2025 23:06:13 +0630
Subject: [PATCH 04/23] fix: add support for partial words
---
src/pipecat/services/elevenlabs/tts.py | 36 +++++++++++++++-----------
1 file changed, 21 insertions(+), 15 deletions(-)
diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py
index 9e4154e4d..1544e94be 100644
--- a/src/pipecat/services/elevenlabs/tts.py
+++ b/src/pipecat/services/elevenlabs/tts.py
@@ -827,6 +827,10 @@ class ElevenLabsHttpTTSService(WordTTSService):
# Store previous text for context within a turn
self._previous_text = ""
+ # Track partial words that span across alignment chunks
+ self._partial_word = ""
+ self._partial_word_start_time = 0.0
+
def language_to_service_language(self, language: Language) -> Optional[str]:
"""Convert pipecat Language to ElevenLabs language code.
@@ -854,6 +858,8 @@ class ElevenLabsHttpTTSService(WordTTSService):
self._cumulative_time = 0
self._started = False
self._previous_text = ""
+ self._partial_word = ""
+ self._partial_word_start_time = 0.0
logger.debug(f"{self}: Reset internal state")
async def start(self, frame: StartFrame):
@@ -888,11 +894,13 @@ class ElevenLabsHttpTTSService(WordTTSService):
def calculate_word_times(self, alignment_info: Mapping[str, Any]) -> List[Tuple[str, float]]:
"""Calculate word timing from character alignment data.
+ This method handles partial words that may span across multiple alignment chunks.
+
Args:
alignment_info: Character timing data from ElevenLabs.
Returns:
- List of (word, timestamp) pairs.
+ List of (word, timestamp) pairs for complete words in this chunk.
Example input data::
@@ -918,30 +926,28 @@ class ElevenLabsHttpTTSService(WordTTSService):
# Build the words and find their start times
words = []
word_start_times = []
- current_word = ""
- first_char_idx = -1
+ # Start with any partial word from previous chunk
+ current_word = self._partial_word
+ word_start_time = self._partial_word_start_time if self._partial_word else None
for i, char in enumerate(chars):
if char == " ":
if current_word: # Only add non-empty words
words.append(current_word)
- # Use time of the first character of the word, offset by cumulative time
- word_start_times.append(
- self._cumulative_time + char_start_times[first_char_idx]
- )
+ word_start_times.append(word_start_time)
current_word = ""
- first_char_idx = -1
+ word_start_time = None
else:
- if not current_word: # This is the first character of a new word
- first_char_idx = i
+ if word_start_time is None: # First character of a new word
+ # Use time of the first character of the word, offset by cumulative time
+ word_start_time = self._cumulative_time + char_start_times[i]
current_word += char
- # Don't forget the last word if there's no trailing space
- if current_word and first_char_idx >= 0:
- words.append(current_word)
- word_start_times.append(self._cumulative_time + char_start_times[first_char_idx])
+ # Store any incomplete word at the end of this chunk
+ self._partial_word = current_word if current_word else ""
+ self._partial_word_start_time = word_start_time if word_start_time is not None else 0.0
- # Create word-time pairs
+ # Create word-time pairs for complete words only
word_times = list(zip(words, word_start_times))
return word_times
From be2858bfbb3df8b814bd4d73ed7316e1f35e4aa9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?=
Date: Tue, 14 Oct 2025 14:09:45 -0700
Subject: [PATCH 05/23] CartesiaSTTService: inherit from WebsocketSTTService
---
CHANGELOG.md | 4 +
src/pipecat/services/cartesia/stt.py | 145 ++++++++++++++-------------
src/pipecat/services/cartesia/tts.py | 2 +-
3 files changed, 80 insertions(+), 71 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 424d6a420..dd3bfa3c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The runner `--folder` argument now supports downloading files from
subdirectories.
+### Changed
+
+- `CartesiaSTTService` now inherits from `WebsocketSTTService`.
+
### Fixed
- Fixed an issue where `RimeHttpTTSService` and `PiperTTSService` could generate
diff --git a/src/pipecat/services/cartesia/stt.py b/src/pipecat/services/cartesia/stt.py
index 5412c422c..97a4f7127 100644
--- a/src/pipecat/services/cartesia/stt.py
+++ b/src/pipecat/services/cartesia/stt.py
@@ -28,13 +28,12 @@ from pipecat.frames.frames import (
UserStoppedSpeakingFrame,
)
from pipecat.processors.frame_processor import FrameDirection
-from pipecat.services.stt_service import STTService
+from pipecat.services.stt_service import WebsocketSTTService
from pipecat.transcriptions.language import Language
from pipecat.utils.time import time_now_iso8601
from pipecat.utils.tracing.service_decorators import traced_stt
try:
- import websockets
from websockets.asyncio.client import connect as websocket_connect
from websockets.protocol import State
except ModuleNotFoundError as e:
@@ -124,7 +123,7 @@ class CartesiaLiveOptions:
return cls(**json.loads(json_str))
-class CartesiaSTTService(STTService):
+class CartesiaSTTService(WebsocketSTTService):
"""Speech-to-text service using Cartesia Live API.
Provides real-time speech transcription through WebSocket connection
@@ -176,8 +175,7 @@ class CartesiaSTTService(STTService):
self.set_model_name(merged_options.model)
self._api_key = api_key
self._base_url = base_url or "api.cartesia.ai"
- self._connection = None
- self._receiver_task = None
+ self._receive_task = None
def can_generate_metrics(self) -> bool:
"""Check if the service can generate processing metrics.
@@ -214,6 +212,27 @@ class CartesiaSTTService(STTService):
await super().cancel(frame)
await self._disconnect()
+ async def start_metrics(self):
+ """Start performance metrics collection for transcription processing."""
+ await self.start_ttfb_metrics()
+ await self.start_processing_metrics()
+
+ async def process_frame(self, frame: Frame, direction: FrameDirection):
+ """Process incoming frames and handle speech events.
+
+ Args:
+ frame: The frame to process.
+ direction: Direction of frame flow in the pipeline.
+ """
+ await super().process_frame(frame, direction)
+
+ if isinstance(frame, UserStartedSpeakingFrame):
+ await self.start_metrics()
+ elif isinstance(frame, UserStoppedSpeakingFrame):
+ # Send finalize command to flush the transcription session
+ if self._websocket and self._websocket.state is State.OPEN:
+ await self._websocket.send("finalize")
+
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
"""Process audio data for speech-to-text transcription.
@@ -224,45 +243,69 @@ class CartesiaSTTService(STTService):
None - transcription results are handled via WebSocket responses.
"""
# If the connection is closed, due to timeout, we need to reconnect when the user starts speaking again
- if not self._connection or self._connection.state is State.CLOSED:
+ if not self._websocket or self._websocket.state is State.CLOSED:
await self._connect()
- await self._connection.send(audio)
+ await self._websocket.send(audio)
yield None
async def _connect(self):
- params = self._settings.to_dict()
- ws_url = f"wss://{self._base_url}/stt/websocket?{urllib.parse.urlencode(params)}"
- logger.debug(f"Connecting to Cartesia: {ws_url}")
- headers = {"Cartesia-Version": "2025-04-16", "X-API-Key": self._api_key}
+ await self._connect_websocket()
+ if self._websocket and not self._receive_task:
+ self._receive_task = asyncio.create_task(self._receive_task_handler(self._report_error))
+
+ async def _disconnect(self):
+ if self._receive_task:
+ await self.cancel_task(self._receive_task)
+ self._receive_task = None
+
+ await self._disconnect_websocket()
+
+ async def _connect_websocket(self):
try:
- self._connection = await websocket_connect(ws_url, additional_headers=headers)
- # Setup the receiver task to handle the incoming messages from the Cartesia server
- if self._receiver_task is None or self._receiver_task.done():
- self._receiver_task = asyncio.create_task(self._receive_messages())
- logger.debug(f"Connected to Cartesia")
+ if self._websocket and self._websocket.state is State.OPEN:
+ return
+ logger.debug("Connecting to Cartesia STT")
+
+ params = self._settings.to_dict()
+ ws_url = f"wss://{self._base_url}/stt/websocket?{urllib.parse.urlencode(params)}"
+ headers = {"Cartesia-Version": "2025-04-16", "X-API-Key": self._api_key}
+
+ self._websocket = await websocket_connect(ws_url, additional_headers=headers)
except Exception as e:
logger.error(f"{self}: unable to connect to Cartesia: {e}")
- async def _receive_messages(self):
+ async def _disconnect_websocket(self):
try:
- while True:
- if not self._connection or self._connection.state is State.CLOSED:
- break
-
- message = await self._connection.recv()
- try:
- data = json.loads(message)
- await self._process_response(data)
- except json.JSONDecodeError:
- logger.warning(f"Received non-JSON message: {message}")
- except asyncio.CancelledError:
- pass
- except websockets.exceptions.ConnectionClosed as e:
- logger.debug(f"WebSocket connection closed: {e}")
+ if self._websocket and self._websocket.state is State.OPEN:
+ logger.debug("Disconnecting from Cartesia STT")
+ await self._websocket.close()
except Exception as e:
- logger.error(f"Error in message receiver: {e}")
+ logger.error(f"{self} error closing websocket: {e}")
+ finally:
+ self._websocket = None
+
+ def _get_websocket(self):
+ if self._websocket:
+ return self._websocket
+ raise Exception("Websocket not connected")
+
+ async def _process_messages(self):
+ async for message in self._get_websocket():
+ try:
+ data = json.loads(message)
+ await self._process_response(data)
+ except json.JSONDecodeError:
+ logger.warning(f"Received non-JSON message: {message}")
+
+ async def _receive_messages(self):
+ while True:
+ await self._process_messages()
+ # Cartesia times out after 5 minutes of innactivity (no keepalive
+ # mechanism is available). So, we try to reconnect.
+ logger.debug(f"{self} Cartesia connection was disconnected (timeout?), reconnecting")
+ await self._connect_websocket()
async def _process_response(self, data):
if "type" in data:
@@ -316,41 +359,3 @@ class CartesiaSTTService(STTService):
language,
)
)
-
- async def _disconnect(self):
- if self._receiver_task:
- self._receiver_task.cancel()
- try:
- await self._receiver_task
- except asyncio.CancelledError:
- pass
- except Exception as e:
- logger.exception(f"Unexpected exception while cancelling task: {e}")
- self._receiver_task = None
-
- if self._connection and self._connection.state is State.OPEN:
- logger.debug("Disconnecting from Cartesia")
-
- await self._connection.close()
- self._connection = None
-
- async def start_metrics(self):
- """Start performance metrics collection for transcription processing."""
- await self.start_ttfb_metrics()
- await self.start_processing_metrics()
-
- async def process_frame(self, frame: Frame, direction: FrameDirection):
- """Process incoming frames and handle speech events.
-
- Args:
- frame: The frame to process.
- direction: Direction of frame flow in the pipeline.
- """
- await super().process_frame(frame, direction)
-
- if isinstance(frame, UserStartedSpeakingFrame):
- await self.start_metrics()
- elif isinstance(frame, UserStoppedSpeakingFrame):
- # Send finalize command to flush the transcription session
- if self._connection and self._connection.state is State.OPEN:
- await self._connection.send("finalize")
diff --git a/src/pipecat/services/cartesia/tts.py b/src/pipecat/services/cartesia/tts.py
index 3b81da5d4..9b2475cb6 100644
--- a/src/pipecat/services/cartesia/tts.py
+++ b/src/pipecat/services/cartesia/tts.py
@@ -344,7 +344,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
try:
if self._websocket and self._websocket.state is State.OPEN:
return
- logger.debug("Connecting to Cartesia")
+ logger.debug("Connecting to Cartesia TTS")
self._websocket = await websocket_connect(
f"{self._url}?api_key={self._api_key}&cartesia_version={self._cartesia_version}"
)
From 0c31b5ef19d21a190b6600d0e2366bb14f9713a9 Mon Sep 17 00:00:00 2001
From: Mark Backman
Date: Wed, 15 Oct 2025 11:49:26 -0400
Subject: [PATCH 06/23] Add aggregate_sentences arg to ElevenLabsHttpTTSService
---
CHANGELOG.md | 3 +++
src/pipecat/services/elevenlabs/tts.py | 4 +++-
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 424d6a420..73a42dd22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Added an `aggregate_sentences` arg in `ElevenLabsHttpTTSService`, where the
+ default value is True.
+
- The runner `--folder` argument now supports downloading files from
subdirectories.
diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py
index 1544e94be..a9bf65376 100644
--- a/src/pipecat/services/elevenlabs/tts.py
+++ b/src/pipecat/services/elevenlabs/tts.py
@@ -774,6 +774,7 @@ class ElevenLabsHttpTTSService(WordTTSService):
base_url: str = "https://api.elevenlabs.io",
sample_rate: Optional[int] = None,
params: Optional[InputParams] = None,
+ aggregate_sentences: Optional[bool] = True,
**kwargs,
):
"""Initialize the ElevenLabs HTTP TTS service.
@@ -786,10 +787,11 @@ class ElevenLabsHttpTTSService(WordTTSService):
base_url: Base URL for ElevenLabs HTTP API.
sample_rate: Audio sample rate. If None, uses default.
params: Additional input parameters for voice customization.
+ aggregate_sentences: Whether to aggregate sentences within the TTSService.
**kwargs: Additional arguments passed to the parent service.
"""
super().__init__(
- aggregate_sentences=True,
+ aggregate_sentences=aggregate_sentences,
push_text_frames=False,
push_stop_frames=True,
sample_rate=sample_rate,
From 83b0dc39f715f350e2509171cabff910d7805885 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?=
Date: Wed, 15 Oct 2025 09:22:48 -0700
Subject: [PATCH 07/23] BaseInputTransport: stop audio filter on cancel
---
CHANGELOG.md | 3 +++
src/pipecat/transports/base_input.py | 3 +++
2 files changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 424d6a420..aaaf0b54c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Fixed an issue where audio filters' `stop()` would not be called when using
+ `CancelFrame`.
+
- Fixed an issue where `RimeHttpTTSService` and `PiperTTSService` could generate
incorrectly 16-bit aligned audio frames, potentially leading to internal
errors or static audio.
diff --git a/src/pipecat/transports/base_input.py b/src/pipecat/transports/base_input.py
index 25cc56f68..0f247c6dc 100644
--- a/src/pipecat/transports/base_input.py
+++ b/src/pipecat/transports/base_input.py
@@ -232,6 +232,9 @@ class BaseInputTransport(FrameProcessor):
"""
# Cancel and wait for the audio input task to finish.
await self._cancel_audio_task()
+ # Stop audio filter.
+ if self._params.audio_in_filter:
+ await self._params.audio_in_filter.stop()
async def set_transport_ready(self, frame: StartFrame):
"""Called when the transport is ready to stream.
From fa5d4ecf869621d96d038f1379030cb04a32c0fc Mon Sep 17 00:00:00 2001
From: Mark Backman
Date: Wed, 15 Oct 2025 12:45:07 -0400
Subject: [PATCH 08/23] Add foundation 47-sentry-metrics.py
---
CHANGELOG.md | 5 +
examples/foundational/47-sentry-metrics.py | 142 +++++++++++++++++++++
2 files changed, 147 insertions(+)
create mode 100644 examples/foundational/47-sentry-metrics.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 424d6a420..3e37b4ef9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
incorrectly 16-bit aligned audio frames, potentially leading to internal
errors or static audio.
+### Other
+
+- Added foundational example `47-sentry-metrics.py`, demonstrating how to use the
+ `SentryMetrics` processor.
+
## [0.0.90] - 2025-10-10
### Added
diff --git a/examples/foundational/47-sentry-metrics.py b/examples/foundational/47-sentry-metrics.py
new file mode 100644
index 000000000..ae7b7a59d
--- /dev/null
+++ b/examples/foundational/47-sentry-metrics.py
@@ -0,0 +1,142 @@
+#
+# Copyright (c) 2024–2025, Daily
+#
+# SPDX-License-Identifier: BSD 2-Clause License
+#
+
+import os
+
+import sentry_sdk
+from dotenv import load_dotenv
+from loguru import logger
+
+from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
+from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
+from pipecat.audio.vad.silero import SileroVADAnalyzer
+from pipecat.audio.vad.vad_analyzer import VADParams
+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
+from pipecat.processors.metrics.sentry import SentryMetrics
+from pipecat.runner.types import 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.websocket.fastapi import FastAPIWebsocketParams
+
+load_dotenv(override=True)
+
+# We store functions so objects (e.g. SileroVADAnalyzer) don't get
+# instantiated. The function will be called when the desired transport gets
+# selected.
+transport_params = {
+ "daily": lambda: DailyParams(
+ audio_in_enabled=True,
+ audio_out_enabled=True,
+ vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
+ turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
+ ),
+ "twilio": lambda: FastAPIWebsocketParams(
+ audio_in_enabled=True,
+ audio_out_enabled=True,
+ vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
+ turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
+ ),
+ "webrtc": lambda: TransportParams(
+ audio_in_enabled=True,
+ audio_out_enabled=True,
+ vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
+ turn_analyzer=LocalSmartTurnAnalyzerV3(params=SmartTurnParams()),
+ ),
+}
+
+
+async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
+ logger.info(f"Starting bot")
+
+ # Initialize Sentry
+ sentry_sdk.init(
+ dsn=os.getenv("SENTRY_DSN"),
+ traces_sample_rate=1.0,
+ )
+
+ stt = DeepgramSTTService(
+ api_key=os.getenv("DEEPGRAM_API_KEY"),
+ metrics=SentryMetrics(),
+ )
+
+ tts = CartesiaTTSService(
+ api_key=os.getenv("CARTESIA_API_KEY"),
+ voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
+ metrics=SentryMetrics(),
+ )
+
+ llm = OpenAILLMService(
+ api_key=os.getenv("OPENAI_API_KEY"),
+ metrics=SentryMetrics(),
+ )
+
+ messages = [
+ {
+ "role": "system",
+ "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way.",
+ },
+ ]
+
+ context = LLMContext(messages)
+ context_aggregator = LLMContextAggregatorPair(context)
+
+ pipeline = Pipeline(
+ [
+ transport.input(), # Transport user input
+ stt,
+ context_aggregator.user(), # User responses
+ llm, # LLM
+ tts, # TTS
+ transport.output(), # Transport bot output
+ context_aggregator.assistant(), # Assistant spoken responses
+ ]
+ )
+
+ task = PipelineTask(
+ pipeline,
+ params=PipelineParams(
+ enable_metrics=True,
+ enable_usage_metrics=True,
+ ),
+ idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
+ )
+
+ @transport.event_handler("on_client_connected")
+ async def on_client_connected(transport, client):
+ logger.info(f"Client connected")
+ # Kick off the conversation.
+ 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(f"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 Cloud."""
+ transport = await create_transport(runner_args, transport_params)
+ await run_bot(transport, runner_args)
+
+
+if __name__ == "__main__":
+ from pipecat.runner.run import main
+
+ main()
From abc569b3d231e4ff1eccf835773e3aa9226a5acc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?=
Date: Tue, 14 Oct 2025 14:10:12 -0700
Subject: [PATCH 09/23] examples(foundational/07): use CartesiaSTTService
---
examples/foundational/07-interruptible.py | 4 ++--
examples/foundational/13f-cartesia-transcription.py | 5 +----
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/examples/foundational/07-interruptible.py b/examples/foundational/07-interruptible.py
index 81ba692c7..1e7bd5718 100644
--- a/examples/foundational/07-interruptible.py
+++ b/examples/foundational/07-interruptible.py
@@ -21,8 +21,8 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import LLMContextAggregatorPair
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
+from pipecat.services.cartesia.stt import CartesiaSTTService
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
@@ -58,7 +58,7 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot")
- stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
+ stt = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY"))
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
diff --git a/examples/foundational/13f-cartesia-transcription.py b/examples/foundational/13f-cartesia-transcription.py
index 913dce797..d685d0ba8 100644
--- a/examples/foundational/13f-cartesia-transcription.py
+++ b/examples/foundational/13f-cartesia-transcription.py
@@ -48,10 +48,7 @@ transport_params = {
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot")
- stt = CartesiaSTTService(
- api_key=os.getenv("CARTESIA_API_KEY"),
- base_url=os.getenv("CARTESIA_BASE_URL"),
- )
+ stt = CartesiaSTTService(api_key=os.getenv("CARTESIA_API_KEY"))
tl = TranscriptionLogger()
From 73877218e948efe5ba3a3ff4c6221d6aae7358f8 Mon Sep 17 00:00:00 2001
From: Mark Backman
Date: Wed, 15 Oct 2025 10:28:03 -0400
Subject: [PATCH 10/23] Add room_properties to the Daily runner configure()
method
---
CHANGELOG.md | 3 ++
src/pipecat/runner/daily.py | 75 +++++++++++++++++++++++++++----------
2 files changed, 59 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76c35a7de..5a51132af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added an `aggregate_sentences` arg in `ElevenLabsHttpTTSService`, where the
default value is True.
+- Added a `room_properties` arg to the Daily runner's `configure()` method,
+ allowing `DailyRoomProperties` to be provided.
+
- The runner `--folder` argument now supports downloading files from
subdirectories.
diff --git a/src/pipecat/runner/daily.py b/src/pipecat/runner/daily.py
index f52ccbc2b..5bff7d060 100644
--- a/src/pipecat/runner/daily.py
+++ b/src/pipecat/runner/daily.py
@@ -82,6 +82,7 @@ async def configure(
sip_enable_video: Optional[bool] = False,
sip_num_endpoints: Optional[int] = 1,
sip_codecs: Optional[Dict[str, List[str]]] = None,
+ room_properties: Optional[DailyRoomProperties] = None,
) -> DailyRoomConfig:
"""Configure Daily room URL and token with optional SIP capabilities.
@@ -99,6 +100,10 @@ async def configure(
sip_num_endpoints: Number of allowed SIP endpoints.
sip_codecs: Codecs to support for audio and video. If None, uses Daily defaults.
Example: {"audio": ["OPUS"], "video": ["H264"]}
+ room_properties: Optional DailyRoomProperties to use instead of building from
+ individual parameters. When provided, this overrides room_exp_duration and
+ SIP-related parameters. If not provided, properties are built from the
+ individual parameters as before.
Returns:
DailyRoomConfig: Object with room_url, token, and optional sip_endpoint.
@@ -115,6 +120,13 @@ async def configure(
# SIP-enabled room
sip_config = await configure(session, sip_caller_phone="+15551234567")
print(f"SIP endpoint: {sip_config.sip_endpoint}")
+
+ # Custom room properties with recording enabled
+ custom_props = DailyRoomProperties(
+ enable_recording="cloud",
+ max_participants=2,
+ )
+ config = await configure(session, room_properties=custom_props)
"""
# Check for required API key
api_key = os.getenv("DAILY_API_KEY")
@@ -124,9 +136,32 @@ async def configure(
"Get your API key from https://dashboard.daily.co/developers"
)
+ # Warn if both room_properties and individual parameters are provided
+ if room_properties is not None:
+ individual_params_provided = any(
+ [
+ room_exp_duration != 2.0,
+ token_exp_duration != 2.0,
+ sip_caller_phone is not None,
+ sip_enable_video is not False,
+ sip_num_endpoints != 1,
+ sip_codecs is not None,
+ ]
+ )
+ if individual_params_provided:
+ logger.warning(
+ "Both room_properties and individual parameters (room_exp_duration, token_exp_duration, "
+ "sip_*) were provided. The room_properties will be used and individual parameters "
+ "will be ignored."
+ )
+
# Determine if SIP mode is enabled
sip_enabled = sip_caller_phone is not None
+ # If room_properties is provided, check if it has SIP configuration
+ if room_properties and room_properties.sip:
+ sip_enabled = True
+
daily_rest_helper = DailyRESTHelper(
daily_api_key=api_key,
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
@@ -150,27 +185,29 @@ async def configure(
room_name = f"{room_prefix}-{uuid.uuid4().hex[:8]}"
logger.info(f"Creating new Daily room: {room_name}")
- # Calculate expiration time
- expiration_time = time.time() + (room_exp_duration * 60 * 60)
+ # Use provided room_properties or build from parameters
+ if room_properties is None:
+ # Calculate expiration time
+ expiration_time = time.time() + (room_exp_duration * 60 * 60)
- # Create room properties
- room_properties = DailyRoomProperties(
- exp=expiration_time,
- eject_at_room_exp=True,
- )
-
- # Add SIP configuration if enabled
- if sip_enabled:
- sip_params = DailyRoomSipParams(
- display_name=sip_caller_phone,
- video=sip_enable_video,
- sip_mode="dial-in",
- num_endpoints=sip_num_endpoints,
- codecs=sip_codecs,
+ # Create room properties
+ room_properties = DailyRoomProperties(
+ exp=expiration_time,
+ eject_at_room_exp=True,
)
- room_properties.sip = sip_params
- room_properties.enable_dialout = True # Enable outbound calls if needed
- room_properties.start_video_off = not sip_enable_video # Voice-only by default
+
+ # Add SIP configuration if enabled
+ if sip_enabled:
+ sip_params = DailyRoomSipParams(
+ display_name=sip_caller_phone,
+ video=sip_enable_video,
+ sip_mode="dial-in",
+ num_endpoints=sip_num_endpoints,
+ codecs=sip_codecs,
+ )
+ room_properties.sip = sip_params
+ room_properties.enable_dialout = True # Enable outbound calls if needed
+ room_properties.start_video_off = not sip_enable_video # Voice-only by default
# Create room parameters
room_params = DailyRoomParams(name=room_name, properties=room_properties)
From 9d78402a3326eb9185d05bd7ccaf8c098d741fb4 Mon Sep 17 00:00:00 2001
From: Mark Backman
Date: Wed, 15 Oct 2025 11:32:07 -0400
Subject: [PATCH 11/23] fix: set apply_text_normalization as request parameter
in ElevenLabsHttpTTSService
---
CHANGELOG.md | 4 ++++
src/pipecat/services/elevenlabs/tts.py | 5 +++--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76c35a7de..4524d8296 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where audio filters' `stop()` would not be called when using
`CancelFrame`.
+- Fixed an issue in `ElevenLabsHttpTTSService`, where
+ `apply_text_normalization` was incorrectly set as a query parameter. It's now
+ being added as a request parameter.
+
- Fixed an issue where `RimeHttpTTSService` and `PiperTTSService` could generate
incorrectly 16-bit aligned audio frames, potentially leading to internal
errors or static audio.
diff --git a/src/pipecat/services/elevenlabs/tts.py b/src/pipecat/services/elevenlabs/tts.py
index a9bf65376..080f54cd6 100644
--- a/src/pipecat/services/elevenlabs/tts.py
+++ b/src/pipecat/services/elevenlabs/tts.py
@@ -985,6 +985,9 @@ class ElevenLabsHttpTTSService(WordTTSService):
if self._voice_settings:
payload["voice_settings"] = self._voice_settings
+ if self._settings["apply_text_normalization"] is not None:
+ payload["apply_text_normalization"] = self._settings["apply_text_normalization"]
+
language = self._settings["language"]
if self._model_name in ELEVENLABS_MULTILINGUAL_MODELS and language:
payload["language_code"] = language
@@ -1005,8 +1008,6 @@ class ElevenLabsHttpTTSService(WordTTSService):
}
if self._settings["optimize_streaming_latency"] is not None:
params["optimize_streaming_latency"] = self._settings["optimize_streaming_latency"]
- if self._settings["apply_text_normalization"] is not None:
- params["apply_text_normalization"] = self._settings["apply_text_normalization"]
try:
await self.start_ttfb_metrics()
From e11ede475b643502c1a90e840cdbdc8f6e1abceb Mon Sep 17 00:00:00 2001
From: Mark Backman
Date: Wed, 15 Oct 2025 13:22:18 -0400
Subject: [PATCH 12/23] Update moondream chatbot README link
---
README.md | 26 ++++++++++-----------
examples/foundational/assets/moondream.png | Bin 0 -> 1101056 bytes
2 files changed, 13 insertions(+), 13 deletions(-)
create mode 100644 examples/foundational/assets/moondream.png
diff --git a/README.md b/README.md
index 50cc72791..6b00832f3 100644
--- a/README.md
+++ b/README.md
@@ -63,24 +63,24 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout
-
+
## 🧩 Available services
-| Category | Services |
-| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
-| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
+| Category | Services |
+| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/stt/elevenlabs), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [NVIDIA Riva](https://docs.pipecat.ai/server/services/stt/riva), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
+| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/server/services/llm/mistral), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
| Text-to-Speech | [Async](https://docs.pipecat.ai/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [Groq](https://docs.pipecat.ai/server/services/tts/groq), [Hume](https://docs.pipecat.ai/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/server/services/tts/inworld), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [NVIDIA Riva](https://docs.pipecat.ai/server/services/tts/riva), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) |
-| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |
-| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local |
-| Serializers | [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx) |
-| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) |
-| Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) |
-| Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/fal), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) |
-| Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) |
-| Analytics & Metrics | [OpenTelemetry](https://docs.pipecat.ai/server/utilities/opentelemetry), [Sentry](https://docs.pipecat.ai/server/services/analytics/sentry) |
+| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |
+| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local |
+| Serializers | [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx) |
+| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) |
+| Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) |
+| Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/fal), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) |
+| Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) |
+| Analytics & Metrics | [OpenTelemetry](https://docs.pipecat.ai/server/utilities/opentelemetry), [Sentry](https://docs.pipecat.ai/server/services/analytics/sentry) |
📚 [View full services documentation →](https://docs.pipecat.ai/server/services/supported-services)
diff --git a/examples/foundational/assets/moondream.png b/examples/foundational/assets/moondream.png
new file mode 100644
index 0000000000000000000000000000000000000000..17ba5401fb2cdfb4d1a5a2e6f9738a09bbf00d28
GIT binary patch
literal 1101056
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