From 7de8838debbc516cf18cad263375a6eeb339ab41 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Mon, 17 Nov 2025 13:25:12 +0100 Subject: [PATCH 01/22] add word-level timestamp support to Hume service --- src/pipecat/services/hume/tts.py | 45 +++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index 6760c8121..b29215727 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -19,7 +19,7 @@ from pipecat.frames.frames import ( TTSStartedFrame, TTSStoppedFrame, ) -from pipecat.services.tts_service import TTSService +from pipecat.services.tts_service import WordTTSService from pipecat.utils.tracing.service_decorators import traced_tts try: @@ -28,6 +28,7 @@ try: FormatPcm, PostedUtterance, PostedUtteranceVoiceWithId, + TimestampType, ) except ModuleNotFoundError as e: # pragma: no cover - import-time guidance logger.error(f"Exception: {e}") @@ -38,7 +39,7 @@ except ModuleNotFoundError as e: # pragma: no cover - import-time guidance HUME_SAMPLE_RATE = 48_000 # Hume TTS streams at 48 kHz -class HumeTTSService(TTSService): +class HumeTTSService(WordTTSService): """Hume Octave Text-to-Speech service. Streams PCM audio via Hume's HTTP output streaming (JSON chunks) endpoint @@ -48,6 +49,7 @@ class HumeTTSService(TTSService): - Generates speech from text using Hume TTS. - Streams PCM audio. + - Supports word timestamps for synchronization with text. - Supports dynamic updates of voice and synthesis parameters at runtime. - Provides metrics for Time To First Byte (TTFB) and TTS usage. """ @@ -85,7 +87,9 @@ class HumeTTSService(TTSService): """ api_key = api_key or os.getenv("HUME_API_KEY") if not api_key: - raise ValueError("HumeTTSService requires an API key (env HUME_API_KEY or api_key=)") + raise ValueError( + "HumeTTSService requires an API key (env HUME_API_KEY or api_key=)" + ) if sample_rate != HUME_SAMPLE_RATE: logger.warning( @@ -101,6 +105,7 @@ class HumeTTSService(TTSService): self.set_voice(voice_id) self._audio_bytes = b"" + self._first_audio_chunk = True def can_generate_metrics(self) -> bool: """Can generate metrics. @@ -193,6 +198,7 @@ class HumeTTSService(TTSService): # Hume emits mono PCM at 48 kHz; downstream can resample if needed. # We buffer audio bytes before sending to prevent glitches. self._audio_bytes = b"" + self._first_audio_chunk = True # Use version "2" by default if no description is provided # Version "1" is needed when description is used @@ -202,11 +208,42 @@ class HumeTTSService(TTSService): format=pcm_fmt, instant_mode=True, version=version, + include_timestamp_types=[TimestampType.WORD], ): + # Check if this is a timestamp chunk + chunk_type = getattr(chunk, "type", None) + if chunk_type == "timestamp": + # Start word timestamps if we haven't received audio yet + if self._first_audio_chunk: + await self.stop_ttfb_metrics() + self.start_word_timestamps() + self._first_audio_chunk = False + # Process word timestamp + timestamp = getattr(chunk, "timestamp", None) + if timestamp: + word_text = getattr(timestamp, "text", None) + time_obj = getattr(timestamp, "time", None) + if word_text and time_obj: + # Convert milliseconds to seconds + begin_ms = getattr(time_obj, "begin", None) + if begin_ms is not None: + begin_seconds = begin_ms / 1000.0 + await self.add_word_timestamps( + [(word_text, begin_seconds)] + ) + continue + + # Process audio chunk audio_b64 = getattr(chunk, "audio", None) if not audio_b64: continue + # Start word timestamps on first audio chunk + if self._first_audio_chunk: + await self.stop_ttfb_metrics() + self.start_word_timestamps() + self._first_audio_chunk = False + pcm_bytes = base64.b64decode(audio_b64) self._audio_bytes += pcm_bytes @@ -230,4 +267,6 @@ class HumeTTSService(TTSService): finally: # Ensure TTFB timer is stopped even on early failures await self.stop_ttfb_metrics() + # Signal end of word timestamps + await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)]) yield TTSStoppedFrame() From 2f2bde9856cc13b060cecd34a60cc75d106906ae Mon Sep 17 00:00:00 2001 From: ivaaan Date: Mon, 17 Nov 2025 13:40:03 +0100 Subject: [PATCH 02/22] add timestamps to example --- .../foundational/07ae-interruptible-hume.py | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index 046f2d4c8..501eafad3 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -13,13 +13,17 @@ 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.frames.frames import Frame, LLMRunFrame, TTSTextFrame 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.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor +from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService @@ -28,9 +32,25 @@ 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 +from pipecat.frames.frames import format_pts load_dotenv(override=True) + +class TimestampLogger(FrameProcessor): + """Frame processor that logs TTSTextFrame objects with their timestamps. + + This helps verify that word timestamps are working correctly by showing + when each word is spoken with its presentation timestamp (PTS). + """ + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, TTSTextFrame): + pts_str = format_pts(frame.pts) if frame.pts else "no PTS" + logger.info(f"🎯 Word timestamp: '{frame.text}' at {pts_str}") + await self.push_frame(frame, direction) + + # We store functions so objects (e.g. SileroVADAnalyzer) don't get # instantiated. The function will be called when the desired transport gets # selected. @@ -81,15 +101,24 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): rtvi = RTVIProcessor(config=RTVIConfig(config=[])) + # Add transcript processor to show timestamps in conversation history + transcript = TranscriptProcessor() + + # Add timestamp logger to verify word timestamps are being generated + timestamp_logger = TimestampLogger() + pipeline = Pipeline( [ transport.input(), # Transport user input rtvi, stt, + transcript.user(), # User transcripts context_aggregator.user(), # User responses llm, # LLM - tts, # TTS + tts, # TTS (HumeTTSService with word timestamps) + timestamp_logger, # Log word timestamps for verification transport.output(), # Transport bot output + transcript.assistant(), # Assistant transcripts with timestamps context_aggregator.assistant(), # Assistant spoken responses ] ) @@ -109,11 +138,23 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_ready(rtvi): await rtvi.set_bot_ready() + @transcript.event_handler("on_transcript_update") + async def on_transcript_update(processor, frame): + """Log transcript updates to show timestamps in conversation.""" + for msg in frame.messages: + timestamp_str = f"[{msg.timestamp}] " if msg.timestamp else "" + logger.info(f"📝 Transcript: {timestamp_str}{msg.role}: {msg.content}") + @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") + logger.info( + "💡 Word timestamps are enabled! Watch for '🎯 Word timestamp' logs showing each word with its PTS." + ) # Kick off the conversation. - messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + messages.append( + {"role": "system", "content": "Please introduce yourself to the user."} + ) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") From 71869a116d06c2f120de059a242c319cbbbf8a0f Mon Sep 17 00:00:00 2001 From: ivaaan Date: Mon, 17 Nov 2025 13:51:04 +0100 Subject: [PATCH 03/22] fix errors --- examples/foundational/07ae-interruptible-hume.py | 4 ++++ src/pipecat/services/hume/tts.py | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index 501eafad3..17a29f0db 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -45,9 +45,13 @@ class TimestampLogger(FrameProcessor): """ async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, TTSTextFrame): pts_str = format_pts(frame.pts) if frame.pts else "no PTS" logger.info(f"🎯 Word timestamp: '{frame.text}' at {pts_str}") + + # Always push all frames through await self.push_frame(frame, direction) diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index b29215727..0ee6e2adf 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -28,7 +28,6 @@ try: FormatPcm, PostedUtterance, PostedUtteranceVoiceWithId, - TimestampType, ) except ModuleNotFoundError as e: # pragma: no cover - import-time guidance logger.error(f"Exception: {e}") @@ -208,7 +207,7 @@ class HumeTTSService(WordTTSService): format=pcm_fmt, instant_mode=True, version=version, - include_timestamp_types=[TimestampType.WORD], + include_timestamp_types=["word"], ): # Check if this is a timestamp chunk chunk_type = getattr(chunk, "type", None) From 9156e217275684231a9d29c75750ea616744360e Mon Sep 17 00:00:00 2001 From: ivaaan Date: Mon, 17 Nov 2025 14:00:03 +0100 Subject: [PATCH 04/22] fix formatting --- examples/foundational/07ae-interruptible-hume.py | 7 ++----- src/pipecat/services/hume/tts.py | 8 ++------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index 17a29f0db..e30cfff98 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -13,7 +13,7 @@ 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 Frame, LLMRunFrame, TTSTextFrame +from pipecat.frames.frames import Frame, LLMRunFrame, TTSTextFrame, format_pts from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -32,7 +32,6 @@ 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 -from pipecat.frames.frames import format_pts load_dotenv(override=True) @@ -156,9 +155,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): "💡 Word timestamps are enabled! Watch for '🎯 Word timestamp' logs showing each word with its PTS." ) # Kick off the conversation. - messages.append( - {"role": "system", "content": "Please introduce yourself to the user."} - ) + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) @transport.event_handler("on_client_disconnected") diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index 0ee6e2adf..c3ef18ddf 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -86,9 +86,7 @@ class HumeTTSService(WordTTSService): """ api_key = api_key or os.getenv("HUME_API_KEY") if not api_key: - raise ValueError( - "HumeTTSService requires an API key (env HUME_API_KEY or api_key=)" - ) + raise ValueError("HumeTTSService requires an API key (env HUME_API_KEY or api_key=)") if sample_rate != HUME_SAMPLE_RATE: logger.warning( @@ -227,9 +225,7 @@ class HumeTTSService(WordTTSService): begin_ms = getattr(time_obj, "begin", None) if begin_ms is not None: begin_seconds = begin_ms / 1000.0 - await self.add_word_timestamps( - [(word_text, begin_seconds)] - ) + await self.add_word_timestamps([(word_text, begin_seconds)]) continue # Process audio chunk From 2a51d0f1e5d4cb1546e214e3f3d75b9cacc8cd50 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Mon, 17 Nov 2025 15:20:06 +0100 Subject: [PATCH 05/22] add changelog --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cda253c7..baed81a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 services that subclass `TTSService` can indicate whether the text in the `TTSTextFrame`s they push already contain any necessary inter-frame spaces. +- Added word-level timestamps support to Hume TTS service + ### Changed - Updated all STT and TTS services to use consistent error handling pattern with @@ -56,8 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and - example wiring; leverages the enhancement model for robust detection with no +- Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and + example wiring; leverages the enhancement model for robust detection with no ONNX dependency or added processing complexity. ## [0.0.94] - 2025-11-10 From 9944e6faf0447f3034e5a3c7e95db0ef1ec387e5 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 18:25:53 +0100 Subject: [PATCH 06/22] upd service based on Mark's suggestions --- src/pipecat/services/hume/tts.py | 126 ++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 45 deletions(-) diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index c3ef18ddf..027a5851b 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -14,11 +14,13 @@ from pydantic import BaseModel from pipecat.frames.frames import ( ErrorFrame, Frame, + InterruptionFrame, StartFrame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame, ) +from pipecat.processors.frame_processor import FrameDirection from pipecat.services.tts_service import WordTTSService from pipecat.utils.tracing.service_decorators import traced_tts @@ -29,6 +31,7 @@ try: PostedUtterance, PostedUtteranceVoiceWithId, ) + from hume.tts.types import TimestampMessage except ModuleNotFoundError as e: # pragma: no cover - import-time guidance logger.error(f"Exception: {e}") logger.error("In order to use Hume, you need to `pip install pipecat-ai[hume]`.") @@ -48,7 +51,7 @@ class HumeTTSService(WordTTSService): - Generates speech from text using Hume TTS. - Streams PCM audio. - - Supports word timestamps for synchronization with text. + - Supports word-level timestamps for precise audio-text synchronization. - Supports dynamic updates of voice and synthesis parameters at runtime. - Provides metrics for Time To First Byte (TTFB) and TTS usage. """ @@ -93,7 +96,12 @@ class HumeTTSService(WordTTSService): f"Hume TTS streams at {HUME_SAMPLE_RATE} Hz; configured sample_rate={sample_rate}" ) - super().__init__(sample_rate=sample_rate, **kwargs) + # WordTTSService sets push_text_frames=False by default, which we want + super().__init__( + sample_rate=sample_rate, + push_text_frames=False, + **kwargs, + ) self._client = AsyncHumeClient(api_key=api_key) self._params = params or HumeTTSService.InputParams() @@ -102,7 +110,10 @@ class HumeTTSService(WordTTSService): self.set_voice(voice_id) self._audio_bytes = b"" - self._first_audio_chunk = True + + # Track cumulative time for word timestamps across utterances + self._cumulative_time = 0.0 + self._started = False def can_generate_metrics(self) -> bool: """Can generate metrics. @@ -128,6 +139,27 @@ class HumeTTSService(WordTTSService): frame: The start frame. """ await super().start(frame) + self._reset_state() + + def _reset_state(self): + """Reset internal state variables.""" + self._cumulative_time = 0.0 + self._started = False + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame and handle state changes. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + await super().push_frame(frame, direction) + if isinstance(frame, (InterruptionFrame, TTSStoppedFrame)): + # Reset timing on interruption or stop + self._reset_state() + + if isinstance(frame, TTSStoppedFrame): + await self.add_word_timestamps([("Reset", 0)]) async def update_setting(self, key: str, value: Any) -> None: """Runtime updates via `TTSUpdateSettingsFrame`. @@ -144,7 +176,7 @@ class HumeTTSService(WordTTSService): if key_l == "voice_id": self.set_voice(str(value)) - logger.info(f"HumeTTSService voice_id set to: {self.voice}") + logger.debug(f"HumeTTSService voice_id set to: {self.voice}") elif key_l == "description": self._params.description = None if value is None else str(value) elif key_l == "speed": @@ -157,7 +189,7 @@ class HumeTTSService(WordTTSService): @traced_tts async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: - """Generate speech from text using Hume TTS. + """Generate speech from text using Hume TTS with word timestamps. Args: text: The text to be synthesized. @@ -188,64 +220,66 @@ class HumeTTSService(WordTTSService): await self.start_ttfb_metrics() await self.start_tts_usage_metrics(text) - yield TTSStartedFrame() + + # Start TTS sequence if not already started + if not self._started: + self.start_word_timestamps() + yield TTSStartedFrame() + self._started = True try: # Instant mode is always enabled here (not user-configurable) # Hume emits mono PCM at 48 kHz; downstream can resample if needed. # We buffer audio bytes before sending to prevent glitches. self._audio_bytes = b"" - self._first_audio_chunk = True # Use version "2" by default if no description is provided # Version "1" is needed when description is used version = "1" if self._params.description is not None else "2" + + # Track the duration of this utterance based on the last timestamp + utterance_duration = 0.0 + async for chunk in self._client.tts.synthesize_json_streaming( utterances=[utterance], format=pcm_fmt, instant_mode=True, version=version, - include_timestamp_types=["word"], + include_timestamp_types=["word"], # Request word-level timestamps ): - # Check if this is a timestamp chunk - chunk_type = getattr(chunk, "type", None) - if chunk_type == "timestamp": - # Start word timestamps if we haven't received audio yet - if self._first_audio_chunk: - await self.stop_ttfb_metrics() - self.start_word_timestamps() - self._first_audio_chunk = False - # Process word timestamp - timestamp = getattr(chunk, "timestamp", None) - if timestamp: - word_text = getattr(timestamp, "text", None) - time_obj = getattr(timestamp, "time", None) - if word_text and time_obj: - # Convert milliseconds to seconds - begin_ms = getattr(time_obj, "begin", None) - if begin_ms is not None: - begin_seconds = begin_ms / 1000.0 - await self.add_word_timestamps([(word_text, begin_seconds)]) - continue - - # Process audio chunk + # Process audio chunks audio_b64 = getattr(chunk, "audio", None) - if not audio_b64: - continue - - # Start word timestamps on first audio chunk - if self._first_audio_chunk: + if audio_b64: await self.stop_ttfb_metrics() - self.start_word_timestamps() - self._first_audio_chunk = False + pcm_bytes = base64.b64decode(audio_b64) + self._audio_bytes += pcm_bytes - pcm_bytes = base64.b64decode(audio_b64) - self._audio_bytes += pcm_bytes + # Buffer audio until we have enough to avoid glitches + if len(self._audio_bytes) >= self.chunk_size: + frame = TTSAudioRawFrame( + audio=self._audio_bytes, + sample_rate=self.sample_rate, + num_channels=1, + ) + yield frame + self._audio_bytes = b"" - # Buffer audio until we have enough to avoid glitches - if len(self._audio_bytes) < self.chunk_size: - continue + # Process timestamp messages + if isinstance(chunk, TimestampMessage): + timestamp = chunk.timestamp + if timestamp.type == "word": + # Convert milliseconds to seconds and add cumulative offset + word_start_time = self._cumulative_time + (timestamp.time.begin / 1000.0) + word_end_time = self._cumulative_time + (timestamp.time.end / 1000.0) + # Track the maximum end time for this utterance + utterance_duration = max(utterance_duration, word_end_time) + + # Add word timestamp + await self.add_word_timestamps([(timestamp.text, word_start_time)]) + + # Flush any remaining audio bytes + if self._audio_bytes: frame = TTSAudioRawFrame( audio=self._audio_bytes, sample_rate=self.sample_rate, @@ -256,12 +290,14 @@ class HumeTTSService(WordTTSService): self._audio_bytes = b"" + # Update cumulative time for next utterance + if utterance_duration > 0: + self._cumulative_time = utterance_duration + except Exception as e: logger.error(f"{self} exception: {e}") await self.push_error(ErrorFrame(error=f"{self} error: {e}")) finally: # Ensure TTFB timer is stopped even on early failures await self.stop_ttfb_metrics() - # Signal end of word timestamps - await self.add_word_timestamps([("TTSStoppedFrame", 0), ("Reset", 0)]) - yield TTSStoppedFrame() + # Let the parent class handle TTSStoppedFrame via push_stop_frames From 26f96d0be8e8d1813157bb92b0b99c1b04535a28 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 18:31:38 +0100 Subject: [PATCH 07/22] upd example --- .../foundational/07ae-interruptible-hume.py | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index e30cfff98..eae9edaa9 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -13,7 +13,8 @@ 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 Frame, LLMRunFrame, TTSTextFrame, format_pts +from pipecat.frames.frames import LLMRunFrame, TTSTextFrame +from pipecat.observers.loggers.debug_log_observer import DebugLogObserver, FrameEndpoint from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.task import PipelineParams, PipelineTask @@ -21,7 +22,6 @@ from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, ) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments @@ -29,6 +29,7 @@ from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.hume.tts import HUME_SAMPLE_RATE, HumeTTSService from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -36,24 +37,6 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) -class TimestampLogger(FrameProcessor): - """Frame processor that logs TTSTextFrame objects with their timestamps. - - This helps verify that word timestamps are working correctly by showing - when each word is spoken with its presentation timestamp (PTS). - """ - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - - if isinstance(frame, TTSTextFrame): - pts_str = format_pts(frame.pts) if frame.pts else "no PTS" - logger.info(f"🎯 Word timestamp: '{frame.text}' at {pts_str}") - - # Always push all frames through - await self.push_frame(frame, direction) - - # We store functions so objects (e.g. SileroVADAnalyzer) don't get # instantiated. The function will be called when the desired transport gets # selected. @@ -107,9 +90,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # Add transcript processor to show timestamps in conversation history transcript = TranscriptProcessor() - # Add timestamp logger to verify word timestamps are being generated - timestamp_logger = TimestampLogger() - pipeline = Pipeline( [ transport.input(), # Transport user input @@ -119,7 +99,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): context_aggregator.user(), # User responses llm, # LLM tts, # TTS (HumeTTSService with word timestamps) - timestamp_logger, # Log word timestamps for verification transport.output(), # Transport bot output transcript.assistant(), # Assistant transcripts with timestamps context_aggregator.assistant(), # Assistant spoken responses @@ -134,7 +113,14 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): audio_out_sample_rate=HUME_SAMPLE_RATE, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[RTVIObserver(rtvi)], + observers=[ + RTVIObserver(rtvi), + DebugLogObserver( + frame_types={ + TTSTextFrame: (BaseOutputTransport, FrameEndpoint.DESTINATION), + } + ), + ], ) @rtvi.event_handler("on_client_ready") @@ -152,7 +138,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_connected(transport, client): logger.info(f"Client connected") logger.info( - "💡 Word timestamps are enabled! Watch for '🎯 Word timestamp' logs showing each word with its PTS." + "💡 Word timestamps are enabled! Watch the console for TTSTextFrame logs showing each word with its PTS." ) # Kick off the conversation. messages.append({"role": "system", "content": "Please introduce yourself to the user."}) From a38f20813505665c03f07b15d8f7ad07d4e36b8b Mon Sep 17 00:00:00 2001 From: Ivan A Date: Tue, 18 Nov 2025 20:30:28 +0100 Subject: [PATCH 08/22] Update examples/foundational/07ae-interruptible-hume.py Co-authored-by: Mark Backman --- examples/foundational/07ae-interruptible-hume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index eae9edaa9..9233e58dd 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -117,7 +117,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): RTVIObserver(rtvi), DebugLogObserver( frame_types={ - TTSTextFrame: (BaseOutputTransport, FrameEndpoint.DESTINATION), + TTSTextFrame: (BaseOutputTransport, FrameEndpoint.SOURCE), } ), ], From 4ae1819645f2b6ef4b9f925ded629946b962d90e Mon Sep 17 00:00:00 2001 From: Ivan A Date: Tue, 18 Nov 2025 20:30:44 +0100 Subject: [PATCH 09/22] Update src/pipecat/services/hume/tts.py Co-authored-by: Mark Backman --- src/pipecat/services/hume/tts.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pipecat/services/hume/tts.py b/src/pipecat/services/hume/tts.py index bba70a759..278626748 100644 --- a/src/pipecat/services/hume/tts.py +++ b/src/pipecat/services/hume/tts.py @@ -100,6 +100,7 @@ class HumeTTSService(WordTTSService): super().__init__( sample_rate=sample_rate, push_text_frames=False, + push_stop_frames=True, **kwargs, ) From c2309efd7eea91779ad4e8c829e8237ca88eb17e Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 20:32:27 +0100 Subject: [PATCH 10/22] rm TranscriptProcessor --- examples/foundational/07ae-interruptible-hume.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index 9233e58dd..a7bea804c 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -23,7 +23,6 @@ from pipecat.processors.aggregators.llm_response_universal import ( LLMContextAggregatorPair, ) from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor -from pipecat.processors.transcript_processor import TranscriptProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.deepgram.stt import DeepgramSTTService @@ -87,9 +86,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): rtvi = RTVIProcessor(config=RTVIConfig(config=[])) - # Add transcript processor to show timestamps in conversation history - transcript = TranscriptProcessor() - pipeline = Pipeline( [ transport.input(), # Transport user input From 4c3fd42b1ccea1be1ff6153398bb5b2e199dd953 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 20:36:45 +0100 Subject: [PATCH 11/22] fix changelog --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83796864b..b346e002d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,8 +90,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevented `HeyGenVideoService` from automatically disconnecting after 5 minutes. -### Added - - Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and example wiring; leverages the enhancement model for robust detection with no ONNX dependency or added processing complexity. From f325eeb95baf01b8cf54421c0e21dde932fb85ad Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 20:41:10 +0100 Subject: [PATCH 12/22] rm TranscriptProcessor 2 --- examples/foundational/07ae-interruptible-hume.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/examples/foundational/07ae-interruptible-hume.py b/examples/foundational/07ae-interruptible-hume.py index a7bea804c..c5de34c85 100644 --- a/examples/foundational/07ae-interruptible-hume.py +++ b/examples/foundational/07ae-interruptible-hume.py @@ -91,12 +91,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): transport.input(), # Transport user input rtvi, stt, - transcript.user(), # User transcripts context_aggregator.user(), # User responses llm, # LLM tts, # TTS (HumeTTSService with word timestamps) transport.output(), # Transport bot output - transcript.assistant(), # Assistant transcripts with timestamps context_aggregator.assistant(), # Assistant spoken responses ] ) @@ -123,13 +121,6 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def on_client_ready(rtvi): await rtvi.set_bot_ready() - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - """Log transcript updates to show timestamps in conversation.""" - for msg in frame.messages: - timestamp_str = f"[{msg.timestamp}] " if msg.timestamp else "" - logger.info(f"📝 Transcript: {timestamp_str}{msg.role}: {msg.content}") - @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") From 771469b834b25e1d679d5de80cceee2e77a2d23b Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 21:39:29 +0100 Subject: [PATCH 13/22] fix changelog --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b346e002d..3806c2062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `ElevenLabsRealtimeSTTService` which implements the Realtime STT service from ElevenLabs. -- Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and - example wiring; leverages the enhancement model for robust detection with no - ONNX dependency or added processing complexity. - - Added word-level timestamps support to Hume TTS service ### Changed From 6484855139dc0d5265bd3210aff9dcc146717645 Mon Sep 17 00:00:00 2001 From: ivaaan Date: Tue, 18 Nov 2025 21:47:46 +0100 Subject: [PATCH 14/22] fix changelog --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3806c2062..47be7d2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,10 +86,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prevented `HeyGenVideoService` from automatically disconnecting after 5 minutes. -- Added ai-coustics integrated VAD (`AICVADAnalyzer`) with `AICFilter` factory and - example wiring; leverages the enhancement model for robust detection with no - ONNX dependency or added processing complexity. - ## [0.0.94] - 2025-11-10 ### Changed From 1fcaf3a4bfd680ab270829ea1c738214b171e2f4 Mon Sep 17 00:00:00 2001 From: Filipi Fuchter Date: Tue, 18 Nov 2025 21:38:49 -0300 Subject: [PATCH 15/22] Revert "Searching in both _function_calls_context_messages and context messages when updating the result." This reverts commit fccc91e923fc16617c45c1e001a086c99cfc6d78. --- .../processors/aggregators/llm_response_universal.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 8c084f2dc..93b1dcfaa 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -802,12 +802,7 @@ class LLMAssistantAggregator(LLMContextAggregator): del self._function_calls_in_progress[frame.tool_call_id] def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any): - def iter_all(): - yield from self._function_calls_context_messages - # In case on long-running function call, the function may already be added to the context - yield from self._context.get_messages() - - for message in iter_all(): + for message in self._function_calls_context_messages: if ( not isinstance(message, LLMSpecificMessage) and message["role"] == "tool" From 793aca6b8b1ae4f6450eb91b38552bc9254dbf61 Mon Sep 17 00:00:00 2001 From: Filipi Fuchter Date: Tue, 18 Nov 2025 21:38:49 -0300 Subject: [PATCH 16/22] Revert "Ensure that the function call results respect the previous LLM context." This reverts commit a510b276e6ba1dbb678f4bd6b3b17fbdc61e409b. --- CHANGELOG.md | 5 -- .../aggregators/llm_response_universal.py | 74 +++++++------------ 2 files changed, 25 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10aa1c662..82da28b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,11 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed a race condition where, if the LLM received instructions to both produce - text and invoke a function call at the same time, the context would not be - updated before the function call result arrived, causing the bot to repeat - itself. - - Fixed an issue in the `Runner` where, when using `SmallWebRTCTransport`, the `request_data` was not being passed to the `SmallWebRTCRunnerArguments` body. diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 93b1dcfaa..d0ac67257 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -591,8 +591,6 @@ class LLMAssistantAggregator(LLMContextAggregator): self._started = 0 self._function_calls_in_progress: Dict[str, Optional[FunctionCallInProgressFrame]] = {} self._context_updated_tasks: Set[asyncio.Task] = set() - self._function_calls_context_messages = [] - self._function_calls_pending_context_updates_callbacks = [] @property def has_function_calls_in_progress(self) -> bool: @@ -649,23 +647,21 @@ class LLMAssistantAggregator(LLMContextAggregator): async def push_aggregation(self): """Push the current assistant aggregation with timestamp.""" - if self._aggregation: - aggregation = self.aggregation_string() - await self.reset() + if not self._aggregation: + return - if aggregation: - self._context.add_message({"role": "assistant", "content": aggregation}) + aggregation = self.aggregation_string() + await self.reset() - # Push context frame - await self.push_context_frame() + if aggregation: + self._context.add_message({"role": "assistant", "content": aggregation}) - # Push timestamp frame with current time - timestamp_frame = LLMContextAssistantTimestampFrame(timestamp=time_now_iso8601()) - await self.push_frame(timestamp_frame) + # Push context frame + await self.push_context_frame() - if self._function_calls_context_messages: - self._flush_function_call_messages_to_context() - await self.push_context_frame(FrameDirection.UPSTREAM) + # Push timestamp frame with current time + timestamp_frame = LLMContextAssistantTimestampFrame(timestamp=time_now_iso8601()) + await self.push_frame(timestamp_frame) async def _handle_llm_run(self, frame: LLMRunFrame): await self.push_context_frame(FrameDirection.UPSTREAM) @@ -685,23 +681,6 @@ class LLMAssistantAggregator(LLMContextAggregator): self._started = 0 await self.reset() - def _flush_function_call_messages_to_context(self): - """Move all function calls messages into context, then clear the list.""" - if self._function_calls_context_messages: - self._context.add_messages(self._function_calls_context_messages) - self._function_calls_context_messages.clear() - - # Call the `on_context_updated` callbacks once the function call results - # are added to the context. Run them in separate tasks to make - # sure we don't block the pipeline. - for callback, task_name in self._function_calls_pending_context_updates_callbacks: - task = self.create_task(callback(), task_name) - self._context_updated_tasks.add(task) - task.add_done_callback(self._context_updated_task_finished) - - # Clear the pending callbacks list - self._function_calls_pending_context_updates_callbacks.clear() - async def _handle_function_calls_started(self, frame: FunctionCallsStartedFrame): function_names = [f"{f.function_name}:{f.tool_call_id}" for f in frame.function_calls] logger.debug(f"{self} FunctionCallsStartedFrame: {function_names}") @@ -714,7 +693,7 @@ class LLMAssistantAggregator(LLMContextAggregator): ) # Update context with the in-progress function call - self._function_calls_context_messages.append( + self._context.add_message( { "role": "assistant", "tool_calls": [ @@ -729,7 +708,7 @@ class LLMAssistantAggregator(LLMContextAggregator): ], } ) - self._function_calls_context_messages.append( + self._context.add_message( { "role": "tool", "content": "IN_PROGRESS", @@ -760,13 +739,6 @@ class LLMAssistantAggregator(LLMContextAggregator): else: self._update_function_call_result(frame.function_name, frame.tool_call_id, "COMPLETED") - # Store the on_context_updated callback along with task name info to be invoked later - if properties and properties.on_context_updated: - task_name = f"{frame.function_name}:{frame.tool_call_id}:on_context_updated" - self._function_calls_pending_context_updates_callbacks.append( - (properties.on_context_updated, task_name) - ) - run_llm = False # Run inference if the function call result requires it. @@ -781,13 +753,17 @@ class LLMAssistantAggregator(LLMContextAggregator): # If this is the last function call in progress, run the LLM. run_llm = not bool(self._function_calls_in_progress) - # Only run if the LLM response has completed (not currently generating), - # otherwise defer execution until push_aggregation() is called - # (triggered by LLMFullResponseEndFrame or interruption). - if not self._started: - self._flush_function_call_messages_to_context() - if run_llm: - await self.push_context_frame(FrameDirection.UPSTREAM) + if run_llm: + await self.push_context_frame(FrameDirection.UPSTREAM) + + # Call the `on_context_updated` callback once the function call result + # is added to the context. Also, run this in a separate task to make + # sure we don't block the pipeline. + if properties and properties.on_context_updated: + task_name = f"{frame.function_name}:{frame.tool_call_id}:on_context_updated" + task = self.create_task(properties.on_context_updated(), task_name) + self._context_updated_tasks.add(task) + task.add_done_callback(self._context_updated_task_finished) async def _handle_function_call_cancel(self, frame: FunctionCallCancelFrame): logger.debug( @@ -802,7 +778,7 @@ class LLMAssistantAggregator(LLMContextAggregator): del self._function_calls_in_progress[frame.tool_call_id] def _update_function_call_result(self, function_name: str, tool_call_id: str, result: Any): - for message in self._function_calls_context_messages: + for message in self._context.get_messages(): if ( not isinstance(message, LLMSpecificMessage) and message["role"] == "tool" From ceaf53fdb0cd9f9f1fe7c58c2ae62e454b8915fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 18 Nov 2025 12:08:09 -0800 Subject: [PATCH 17/22] LLMContext: async create_image_message/create_audio_message fixes --- CHANGELOG.md | 7 ++++--- .../22d-natural-conversation-gemini-audio.py | 2 +- src/pipecat/processors/aggregators/llm_context.py | 14 ++++++++------ .../aggregators/llm_response_universal.py | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47be7d2bf..5ace3aa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- ⚠️ Breaking change: `LLMContext.create_image_message()` and - `LLMContext.create_audio_message()` are now async methods. This fixes and - issue where the asyncio event loop would be blocked while encoding audio or +- ⚠️ Breaking change: `LLMContext.create_image_message()`, + `LLMContext.create_audio_message()`, `LLMContext.add_image_frame_message()` + and `LLMContext.add_audio_frames_message()` are now async methods. This fixes + an issue where the asyncio event loop would be blocked while encoding audio or images. - `ConsumerProcessor` now queues frames from the producer internally instead of diff --git a/examples/foundational/22d-natural-conversation-gemini-audio.py b/examples/foundational/22d-natural-conversation-gemini-audio.py index 7a7155297..a7837ce60 100644 --- a/examples/foundational/22d-natural-conversation-gemini-audio.py +++ b/examples/foundational/22d-natural-conversation-gemini-audio.py @@ -391,7 +391,7 @@ class AudioAccumulator(FrameProcessor): ) self._user_speaking = False context = LLMContext() - context.add_audio_frames_message(audio_frames=self._audio_frames) + await context.add_audio_frames_message(audio_frames=self._audio_frames) await self.push_frame(LLMContextFrame(context=context)) elif isinstance(frame, InputAudioRawFrame): # Append the audio frame to our buffer. Treat the buffer as a ring buffer, dropping the oldest diff --git a/src/pipecat/processors/aggregators/llm_context.py b/src/pipecat/processors/aggregators/llm_context.py index b9216103a..99b9aeaa9 100644 --- a/src/pipecat/processors/aggregators/llm_context.py +++ b/src/pipecat/processors/aggregators/llm_context.py @@ -180,7 +180,7 @@ class LLMContext: text: Optional text to include with the audio. """ - def encode_audio(): + async def encode_audio(): sample_rate = audio_frames[0].sample_rate num_channels = audio_frames[0].num_channels @@ -198,7 +198,7 @@ class LLMContext: encoded_audio = base64.b64encode(buffer.getvalue()).decode("utf-8") return encoded_audio - encoded_audio = asyncio.to_thread(encode_audio) + encoded_audio = await asyncio.to_thread(encode_audio) content.append( { @@ -333,7 +333,7 @@ class LLMContext: """ self._tool_choice = tool_choice - def add_image_frame_message( + async def add_image_frame_message( self, *, format: str, size: tuple[int, int], image: bytes, text: Optional[str] = None ): """Add a message containing an image frame. @@ -344,10 +344,12 @@ class LLMContext: image: Raw image bytes. text: Optional text to include with the image. """ - message = LLMContext.create_image_message(format=format, size=size, image=image, text=text) + message = await LLMContext.create_image_message( + format=format, size=size, image=image, text=text + ) self.add_message(message) - def add_audio_frames_message( + async def add_audio_frames_message( self, *, audio_frames: list[AudioRawFrame], text: str = "Audio follows" ): """Add a message containing audio frames. @@ -356,7 +358,7 @@ class LLMContext: audio_frames: List of audio frame objects to include. text: Optional text to include with the audio. """ - message = LLMContext.create_audio_message(audio_frames=audio_frames, text=text) + message = await LLMContext.create_audio_message(audio_frames=audio_frames, text=text) self.add_message(message) @staticmethod diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index d0ac67257..87974bed2 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -793,7 +793,7 @@ class LLMAssistantAggregator(LLMContextAggregator): logger.debug(f"{self} Appending UserImageRawFrame to LLM context (size: {frame.size})") - self._context.add_image_frame_message( + await self._context.add_image_frame_message( format=frame.format, size=frame.size, image=frame.image, From 39b4e6183765b8d59d34d0d93900d4b7057e9b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 18 Nov 2025 13:50:47 -0800 Subject: [PATCH 18/22] SimliVideoService: fix connection issue --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- src/pipecat/services/simli/video.py | 16 +++++++++++++--- uv.lock | 9 +++++---- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ace3aa14..ff3934b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed a `SimliVideoService` connection issue. + - Fixed an issue in the `Runner` where, when using `SmallWebRTCTransport`, the `request_data` was not being passed to the `SmallWebRTCRunnerArguments` body. diff --git a/pyproject.toml b/pyproject.toml index e313c789d..9354c2066 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ local-smart-turn = [ "coremltools>=8.0", "transformers", "torch>=2.5.0,<3", "tor local-smart-turn-v3 = [ "transformers", "onnxruntime>=1.20.1,<2" ] remote-smart-turn = [] silero = [ "onnxruntime>=1.20.1,<2" ] -simli = [ "simli-ai~=0.1.25"] +simli = [ "simli-ai~=1.0.3"] soniox = [ "pipecat-ai[websockets-base]" ] soundfile = [ "soundfile~=0.13.1" ] speechmatics = [ "speechmatics-rt>=0.5.0" ] diff --git a/src/pipecat/services/simli/video.py b/src/pipecat/services/simli/video.py index bac54f35b..e6514ca7b 100644 --- a/src/pipecat/services/simli/video.py +++ b/src/pipecat/services/simli/video.py @@ -84,6 +84,10 @@ class SimliVideoService(FrameProcessor): Please use 'api_key' and 'face_id' parameters instead. use_turn_server: Whether to use TURN server for connection. Defaults to False. + + .. deprecated:: 0.0.95 + The 'use_turn_server' parameter is deprecated and will be removed in a future version. + latency_interval: Latency interval setting for sending health checks to check the latency to Simli Servers. Defaults to 0. simli_url: URL of the simli servers. Can be changed for custom deployments @@ -135,14 +139,20 @@ class SimliVideoService(FrameProcessor): config = SimliConfig(**config_kwargs) + if use_turn_server: + warnings.warn( + "The 'use_turn_server' parameter is deprecated and will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + self._initialized = False # Add buffer time to session limits config.maxIdleTime += 5 config.maxSessionLength += 5 self._simli_client = SimliClient( - config, - use_turn_server, - latency_interval, + config=config, + latencyInterval=latency_interval, simliURL=simli_url, ) diff --git a/uv.lock b/uv.lock index eabe03714..6dec2d8dc 100644 --- a/uv.lock +++ b/uv.lock @@ -4727,7 +4727,7 @@ requires-dist = [ { name = "resampy", specifier = "~=0.4.3" }, { name = "sarvamai", marker = "extra == 'sarvam'", specifier = "==0.1.21" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.28.0,<3" }, - { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=0.1.25" }, + { name = "simli-ai", marker = "extra == 'simli'", specifier = "~=1.0.3" }, { name = "soundfile", marker = "extra == 'soundfile'", specifier = "~=0.13.1" }, { name = "soxr", specifier = "~=0.5.0" }, { name = "speechmatics-rt", marker = "extra == 'speechmatics'", specifier = ">=0.5.0" }, @@ -6496,18 +6496,19 @@ wheels = [ [[package]] name = "simli-ai" -version = "0.1.25" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiortc" }, { name = "av" }, { name = "httpx" }, + { name = "livekit" }, { name = "numpy" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/6a/b28f90baf76f6a60865985f6233ff44abc72d45b66b76658bff3961e20a7/simli_ai-0.1.25.tar.gz", hash = "sha256:7a00b3426dc26a6a421641072c3e49014b7950c621cf4544152f35c58d13fcff", size = 13182, upload-time = "2025-11-06T16:27:08.862Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/03/b0b3e12c68fd3f9c57f6afeee67841349e4866b88760f413357af3043ae4/simli_ai-1.0.3.tar.gz", hash = "sha256:e96b0621a1dbd9582b2ae3d51eefd4995983b49c1f1061eb9239707b15a1ee27", size = 13350, upload-time = "2025-11-13T12:22:32.514Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/57/ae1032fd88214ea4ee6d3028c817c12a999eb90a67766bbab31e9819385a/simli_ai-0.1.25-py3-none-any.whl", hash = "sha256:7d01f65321dc9052f25e15d0463af6a20a86c6d37d9a7b3a2c4b01cbec0a54ed", size = 13651, upload-time = "2025-11-06T16:27:07.765Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d1/dc382ba529de0d2d51f35e9bfd20b41d8f5c96404a3aa24bae97a5a5e51f/simli_ai-1.0.3-py3-none-any.whl", hash = "sha256:ffafa7540aa28833e207be8f3b199367c7f500dac1a8ba0108395bfb7d8362bc", size = 13863, upload-time = "2025-11-13T12:22:31.218Z" }, ] [[package]] From 51ba245e1030af0f7b7366f5d804fff3d6165c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 18 Nov 2025 14:13:34 -0800 Subject: [PATCH 19/22] scripts(evals): fix EVAL_CONVERSATION/EVAL_WEATHER eval --- scripts/evals/run-release-evals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/evals/run-release-evals.py b/scripts/evals/run-release-evals.py index da6df053d..4f8268d78 100644 --- a/scripts/evals/run-release-evals.py +++ b/scripts/evals/run-release-evals.py @@ -30,8 +30,8 @@ EVAL_SIMPLE_MATH = EvalConfig( ) EVAL_WEATHER = EvalConfig( - prompt="What's the weather in San Francisco?", - eval="The user says something specific about the current weather in San Francisco, including the degrees.", + prompt="What's the weather in San Francisco (in farhenheit or celsius)?", + eval="The user says something specific about the current weather in San Francisco, including the degrees (in farhenheit or celsius).", ) EVAL_ONLINE_SEARCH = EvalConfig( @@ -70,7 +70,7 @@ EVAL_VOICEMAIL = EvalConfig( EVAL_CONVERSATION = EvalConfig( prompt="Hello, this is Mark.", - eval="The user replies with a greeting.", + eval="The user acknowledges the greeting.", eval_speaks_first=True, ) From cf1a9c1548d46b616a071bedcf3991ee8bd04ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Tue, 18 Nov 2025 10:54:44 -0800 Subject: [PATCH 20/22] update CHANGELOG for 0.0.95 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3934b2e..d69eb897a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to **Pipecat** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.0.95] - 2025-11-18 ### Added From 42423bff41892d28592ac2cd8f80a306fa70213e Mon Sep 17 00:00:00 2001 From: vipyne Date: Tue, 18 Nov 2025 19:29:32 -0600 Subject: [PATCH 21/22] update MCP foundational examples --- examples/foundational/39-mcp-stdio.py | 2 +- examples/foundational/39a-mcp-run-sse.py | 159 ---------------------- examples/foundational/39b-multiple-mcp.py | 52 ++++--- 3 files changed, 33 insertions(+), 180 deletions(-) delete mode 100644 examples/foundational/39a-mcp-run-sse.py diff --git a/examples/foundational/39-mcp-stdio.py b/examples/foundational/39-mcp-stdio.py index b88ee30b1..7127d831f 100644 --- a/examples/foundational/39-mcp-stdio.py +++ b/examples/foundational/39-mcp-stdio.py @@ -155,7 +155,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. You have access to tools to search the Rijksmuseum collection. - Offer, for example, to show the earliest Rembrandt work from the museum. Use the `search_artwork` tool. + Offer, for example, to show a floral still life, use the `search_artwork` tool. The tool may respond with a JSON object with an `artworks` array. Choose the art from that array. Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. diff --git a/examples/foundational/39a-mcp-run-sse.py b/examples/foundational/39a-mcp-run-sse.py deleted file mode 100644 index 328af6ce4..000000000 --- a/examples/foundational/39a-mcp-run-sse.py +++ /dev/null @@ -1,159 +0,0 @@ -# -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - - -import os - -from dotenv import load_dotenv -from loguru import logger -from mcp.client.session_group import SseServerParameters - -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.runner.types import RunnerArguments -from pipecat.runner.utils import create_transport -from pipecat.services.anthropic.llm import AnthropicLLMService -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.deepgram.stt import DeepgramSTTService -from pipecat.services.mcp_service import MCPClient -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") - - 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 = AnthropicLLMService( - api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-7-sonnet-latest" - ) - - try: - # https://docs.mcp.run/integrating/tutorials/mcp-run-sse-openai-agents/ - mcp = MCPClient(server_params=SseServerParameters(url=os.getenv("MCP_RUN_SSE_URL"))) - except Exception as e: - logger.error(f"error setting up mcp") - logger.exception("error trace:") - - tools = {} - try: - tools = await mcp.register_tools(llm) - except Exception as e: - logger.error(f"error registering tools") - logger.exception("error trace:") - - system = f""" - You are a helpful LLM in a WebRTC call. - Your goal is to demonstrate your capabilities in a succinct way. - You have access to a number of tools provided by mcp.run. Use any and all tools to help users. - 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. - When asked for today's date, use 'https://www.datetoday.net/'. - Don't overexplain what you are doing. - Just respond with short sentences when you are carrying out tool calls. - """ - - messages = [{"role": "system", "content": system}] - - context = LLMContext(messages, tools) - context_aggregator = LLMContextAggregatorPair(context) - - pipeline = Pipeline( - [ - transport.input(), # Transport user input - stt, - context_aggregator.user(), # User spoken responses - llm, # LLM - tts, # TTS - transport.output(), # Transport bot output - context_aggregator.assistant(), # Assistant spoken responses and tool context - ] - ) - - 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: {client}") - # Kick off the conversation. - 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__": - if not os.getenv("MCP_RUN_SSE_URL"): - logger.error( - f"Please set MCP_RUN_SSE_URL environment variable for this example. See https://mcp.run" - ) - import sys - - sys.exit(1) - - from pipecat.runner.run import main - - main() diff --git a/examples/foundational/39b-multiple-mcp.py b/examples/foundational/39b-multiple-mcp.py index dad059208..88fdc3610 100644 --- a/examples/foundational/39b-multiple-mcp.py +++ b/examples/foundational/39b-multiple-mcp.py @@ -7,6 +7,7 @@ import asyncio import io +import json import os import re import shutil @@ -15,7 +16,7 @@ import aiohttp from dotenv import load_dotenv from loguru import logger from mcp import StdioServerParameters -from mcp.client.session_group import SseServerParameters +from mcp.client.session_group import StreamableHttpParameters from PIL import Image from pipecat.adapters.schemas.tools_schema import ToolsSchema @@ -66,10 +67,12 @@ class UrlToImageProcessor(FrameProcessor): await self.push_frame(frame, direction) def extract_url(self, text: str): - pattern = r"!\[[^\]]*\]\((https?://[^)]+\.(png|jpg|jpeg|PNG|JPG|JPEG|gif))\)" - match = re.search(pattern, text) - if match: - return match.group(1) + data = json.loads(text) + if "artObject" in data: + return data["artObject"]["webImage"]["url"] + if "artworks" in data and len(data["artworks"]): + return data["artworks"][0]["webImage"]["url"] + return None async def run_image_process(self, image_url: str): @@ -132,10 +135,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): system = f""" You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. - You have access to tools to search the Rijksmuseum collection. - Offer, for example, to show the earliest Rembrandt work from the museum. Use the `search_artwork` tool. + You have access to tools to search the Rijksmuseum collection and the user's GitHub repositories and account. + Offer, for example, to show a floral still life, use the `search_artwork` tool. The tool may respond with a JSON object with an `artworks` array. Choose the art from that array. Once the tool has responded, tell the user the title and use the `open_image_in_browser` tool. + You can also offer to answer users questions about their GitHub repositories and account. 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. Don't overexplain what you are doing. @@ -145,11 +149,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): messages = [{"role": "system", "content": system}] try: - mcp = MCPClient( + rijksmuseum_mcp = MCPClient( server_params=StdioServerParameters( command=shutil.which("npx"), # https://github.com/r-huijts/rijksmuseum-mcp - args=["-y", "mcp-server-error setting up mcp"], + args=["-y", "mcp-server-rijksmuseum"], env={"RIJKSMUSEUM_API_KEY": os.getenv("RIJKSMUSEUM_API_KEY")}, ) ) @@ -157,24 +161,32 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.error(f"error setting up rijksmuseum mcp") logger.exception("error trace:") try: - # https://docs.mcp.run/integrating/tutorials/mcp-run-sse-openai-agents/ - # ie. "https://www.mcp.run/api/mcp/sse?..." - # ensure the profile has a tool or few installed - mcp_run = MCPClient(server_params=SseServerParameters(url=os.getenv("MCP_RUN_SSE_URL"))) + # Github MCP docs: https://github.com/github/github-mcp-server + # Enable Github Copilot on your GitHub account. Free tier is ok. (https://github.com/settings/copilot) + # Generate a personal access token. It must be a Fine-grained token, classic tokens are not supported. (https://github.com/settings/personal-access-tokens) + # Set permissions you want to use (eg. "all repositories", "profile: read/write", etc) + github_mcp = MCPClient( + server_params=StreamableHttpParameters( + url="https://api.githubcopilot.com/mcp/", + headers={ + "Authorization": f"Bearer {os.getenv('GITHUB_PERSONAL_ACCESS_TOKEN')}" + }, + ) + ) except Exception as e: logger.error(f"error setting up mcp.run") logger.exception("error trace:") - tools = {} - run_tools = {} + rijksmuseum_tools = {} + github_tools = {} try: - tools = await mcp.register_tools(llm) - run_tools = await mcp_run.register_tools(llm) + rijksmuseum_tools = await rijksmuseum_mcp.register_tools(llm) + github_tools = await github_mcp.register_tools(llm) except Exception as e: logger.error(f"error registering tools") logger.exception("error trace:") - all_standard_tools = run_tools.standard_tools + tools.standard_tools + all_standard_tools = rijksmuseum_tools.standard_tools + github_tools.standard_tools all_tools = ToolsSchema(standard_tools=all_standard_tools) context = LLMContext(messages, all_tools) @@ -226,9 +238,9 @@ async def bot(runner_args: RunnerArguments): if __name__ == "__main__": - if not os.getenv("RIJKSMUSEUM_API_KEY") or not os.getenv("MCP_RUN_SSE_URL"): + if not os.getenv("RIJKSMUSEUM_API_KEY") or not os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN"): logger.error( - f"Please set RIJKSMUSEUM_API_KEY and MCP_RUN_SSE_URL environment variables. See https://github.com/r-huijts/rijksmuseum-mcp and https://mcp.run" + f"Please set `RIJKSMUSEUM_API_KEY` and `GITHUB_PERSONAL_ACCESS_TOKEN` environment variables. See https://github.com/r-huijts/rijksmuseum-mcp." ) import sys From 68292bd75fb0b48f74c6f82ffcdb68b47424258a Mon Sep 17 00:00:00 2001 From: vipyne Date: Wed, 19 Nov 2025 10:34:13 -0600 Subject: [PATCH 22/22] rename MCP foundational examples --- .../{39c-mcp-run-http.py => 39a-mcp-streamable-http.py} | 0 ...http-gemini-live.py => 39b-mcp-streamable-http-gemini-live.py} | 0 .../foundational/{39b-multiple-mcp.py => 39c-multiple-mcp.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename examples/foundational/{39c-mcp-run-http.py => 39a-mcp-streamable-http.py} (100%) rename examples/foundational/{39d-mcp-run-http-gemini-live.py => 39b-mcp-streamable-http-gemini-live.py} (100%) rename examples/foundational/{39b-multiple-mcp.py => 39c-multiple-mcp.py} (100%) diff --git a/examples/foundational/39c-mcp-run-http.py b/examples/foundational/39a-mcp-streamable-http.py similarity index 100% rename from examples/foundational/39c-mcp-run-http.py rename to examples/foundational/39a-mcp-streamable-http.py diff --git a/examples/foundational/39d-mcp-run-http-gemini-live.py b/examples/foundational/39b-mcp-streamable-http-gemini-live.py similarity index 100% rename from examples/foundational/39d-mcp-run-http-gemini-live.py rename to examples/foundational/39b-mcp-streamable-http-gemini-live.py diff --git a/examples/foundational/39b-multiple-mcp.py b/examples/foundational/39c-multiple-mcp.py similarity index 100% rename from examples/foundational/39b-multiple-mcp.py rename to examples/foundational/39c-multiple-mcp.py