Merge branch 'main' into richtermb/push-more-error-frames

This commit is contained in:
richtermb
2025-08-05 13:23:24 -07:00
33 changed files with 505 additions and 255 deletions

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.7 rev: v0.12.1
hooks: hooks:
- id: ruff - id: ruff
language_version: python3 language_version: python3

View File

@@ -7,10 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- The development runner how handles custom `body` data for `DailyTransport`.
The `body` data is passed to the Pipecat client. You can POST to the `/start`
endpoint with a request body of:
```
{
"createDailyRoom": true,
"dailyRoomProperties": { "start_video_off": true },
"body": { "custom_data": "value" }
}
```
The `body` information is parsed and used in the application. The
`dailyRoomProperties` are currently not handled.
- Added detailed latency logging to `UserBotLatencyLogObserver`, capturing
average response time between user stop and bot start, as well as minimum and
maximum response latency.
### Changed
- The development runners `/connect` and `/start` endpoint now both return
`dailyRoom` and `dailyToken` in place of the previous `room_url` and `token`.
- Updated the `pipecat.runner.daily` utility to only a take `DAILY_API_URL` and
`DAILY_SAMPLE_ROOM_URL` environment variables instead of argparsing `-u` and
`-k`, respectively.
- Updated `daily-python` to 0.19.6.
- Changed `TavusVideoService` to send audio or video frames only after the
transport is ready, preventing warning messages at startup.
- The development runner now strips any provided protocol (e.g. https://) from
the proxy address and issues a warning. It also strips trailing `/`.
### Fixed ### Fixed
- Fixed an issue in `LiveKitTransport` where empty `AudioRawFrame`s were pushed - Fixed an issue in `LiveKitTransport` where empty `AudioRawFrame`s were pushed
down the pipeline. This resulted in warnings by the STT processor. down the pipeline. This resulted in warnings by the STT processor.
- Fixed `PiperTTSService` to send text as a JSON object in the request body,
resolving compatibility with Piper's HTTP API.
- Fixed an issue with the `TavusVideoService` where an error was thrown due to
missing transcription callbacks.
- Fixed an issue in `SpeechmaticsSTTService` where the `user_id` was set to
`None` when diarization is not enabled.
### Performance
- Fixed an issue in `TaskObserver` (a proxy to all observers) that was degrading
global performance.
### Deprecated
- In the `pipecat.runner.daily`, the `configure_with_args()` function is
deprecated. Use the `configure()` function instead.
- The development runner's `/connect` endpoint is deprecated and will be
removed in a future version. Use the `/start` endpoint in its place. In the
meantime, both endpoints work and deliver equivalent functionality.
- Added `source` field to `ErrorFrame` to indicate `FrameProcessor` that generated the error. - Added `source` field to `ErrorFrame` to indicate `FrameProcessor` that generated the error.
@@ -209,6 +269,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
pushed by the `BaseInputTransport` at Start and any time a pushed by the `BaseInputTransport` at Start and any time a
`VADParamsUpdateFrame` is received. `VADParamsUpdateFrame` is received.
- Added support for Simli Trinity Avatars. A new `is_trinity_avatar` parameter
has been introduced to specify whether the provided `faceId` corresponds to a
Trinity avatar, which is required for optimal Trinity avatar performance.
### Changed ### Changed
- Two package dependencies have been updated: - Two package dependencies have been updated:

View File

@@ -69,80 +69,80 @@ You can connect to Pipecat from any platform using our official SDKs:
## ⚡ Getting started ## ⚡ Getting started
You can get started with Pipecat running on your local machine, then move your agent processes to the cloud when youre ready. You can get started with Pipecat running on your local machine, then move your agent processes to the cloud when you're ready.
```shell 1. Install uv
# Install the module
pip install pipecat-ai
# Set up your environment ```bash
cp dot-env.template .env curl -LsSf https://astral.sh/uv/install.sh | sh
``` ```
To keep things lightweight, only the core framework is included by default. If you need support for third-party AI services, you can add the necessary dependencies with: > **Need help?** Refer to the [uv install documentation](https://docs.astral.sh/uv/getting-started/installation/).
```shell 2. Install the module
pip install "pipecat-ai[option,...]"
``` ```bash
# For new projects
uv init my-pipecat-app
cd my-pipecat-app
uv add pipecat-ai
# Or for existing projects
uv add pipecat-ai
```
3. Set up your environment
```bash
cp env.example .env
```
4. To keep things lightweight, only the core framework is included by default. If you need support for third-party AI services, you can add the necessary dependencies with:
```bash
uv add "pipecat-ai[option,...]"
```
> **Using pip?** You can still use `pip install pipecat-ai` and `pip install "pipecat-ai[option,...]"` to get set up.
## 🧪 Code examples ## 🧪 Code examples
- [Foundational](https://github.com/pipecat-ai/pipecat/tree/main/examples/foundational) — small snippets that build on each other, introducing one or two concepts at a time - [Foundational](https://github.com/pipecat-ai/pipecat/tree/main/examples/foundational) — small snippets that build on each other, introducing one or two concepts at a time
- [Example apps](https://github.com/pipecat-ai/pipecat-examples) — complete applications that you can use as starting points for development - [Example apps](https://github.com/pipecat-ai/pipecat-examples) — complete applications that you can use as starting points for development
## 🛠️ Hacking on the framework itself ## 🛠️ Contributing to the framework
1. Set up a virtual environment before following these instructions. From the root of the repo: 1. Clone the repository and navigate to it:
```shell ```bash
python3 -m venv venv git clone https://github.com/pipecat-ai/pipecat.git
source venv/bin/activate cd pipecat
``` ```
2. Install the development dependencies: 2. Install development and testing dependencies:
```shell ```bash
pip install -r dev-requirements.txt uv sync --group dev --all-extras --no-extra krisp
``` ```
3. Install the git pre-commit hooks (these help ensure your code follows project rules): 3. Install the git pre-commit hooks:
```shell ```bash
pre-commit install uv run pre-commit install
```
4. Install the `pipecat-ai` package locally in editable mode:
```shell
pip install -e .
```
> The `-e` or `--editable` option allows you to modify the code without reinstalling.
5. Include optional dependencies as needed. For example:
```shell
pip install -e ".[daily,deepgram,cartesia,openai,silero]"
```
6. (Optional) If you want to use this package from another directory:
```shell
pip install "path_to_this_repo[option,...]"
``` ```
### Running tests ### Running tests
Install the test dependencies: To run all tests, from the root directory:
```shell ```bash
pip install -r test-requirements.txt uv run pytest
``` ```
From the root directory, run: Run a specific test suite:
```shell ```bash
pytest uv run pytest tests/test_name.py
``` ```
### Setting up your editor ### Setting up your editor

View File

@@ -122,7 +122,6 @@ SONIOX_API_KEY=
# Speechmatics # Speechmatics
SPEECHMATICS_API_KEY=... SPEECHMATICS_API_KEY=...
# SambaNova # SambaNova
SAMBANOVA_API_KEY=... SAMBANOVA_API_KEY=...

View File

@@ -9,7 +9,6 @@ import json
import os import os
import sys import sys
from deepgram import LiveOptions
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
@@ -51,12 +50,7 @@ async def main():
), ),
) )
stt = DeepgramSTTService( stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
api_key=os.getenv("DEEPGRAM_API_KEY"),
live_options=LiveOptions(
vad_events=True,
),
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
@@ -78,20 +72,20 @@ async def main():
context = OpenAILLMContext(messages) context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context) context_aggregator = llm.create_context_aggregator(context)
runner = PipelineRunner() 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( task = PipelineTask(
Pipeline( pipeline,
[
transport.input(),
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
],
),
params=PipelineParams( params=PipelineParams(
enable_metrics=True, enable_metrics=True,
enable_usage_metrics=True, enable_usage_metrics=True,
@@ -132,6 +126,8 @@ async def main():
], ],
) )
runner = PipelineRunner()
await runner.run(task) await runner.run(task)

View File

@@ -25,6 +25,8 @@ from pipecat.processors.aggregators.llm_response import (
LLMUserResponseAggregator, LLMUserResponseAggregator,
) )
from pipecat.processors.frameworks.langchain import LangchainProcessor from pipecat.processors.frameworks.langchain import LangchainProcessor
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import argparse
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,6 +14,8 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.deepgram.stt import DeepgramSTTService, Language, LiveOptions from pipecat.services.deepgram.stt import DeepgramSTTService, Language, LiveOptions
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import argparse
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,6 +14,8 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.gladia.config import ( from pipecat.services.gladia.config import (
GladiaInputParams, GladiaInputParams,
LanguageConfig, LanguageConfig,

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import argparse
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -15,6 +14,8 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.assemblyai.stt import AssemblyAISTTService from pipecat.services.assemblyai.stt import AssemblyAISTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams

View File

@@ -49,7 +49,7 @@ async def run_bot(transport: BaseTransport):
This example will use diarization within our STT service and output the words spoken by This example will use diarization within our STT service and output the words spoken by
each individual speaker and wrap them with XML tags. each individual speaker and wrap them with XML tags.
If you do not wish to use diarization, then set the `enable_speaker_diarization` parameter If you do not wish to use diarization, then set the `enable_diarization` parameter
to `False` or omit it altogether. The `text_format` will only be used if diarization is enabled. to `False` or omit it altogether. The `text_format` will only be used if diarization is enabled.
By default, this example will use our ENHANCED operating point, which is optimized for By default, this example will use our ENHANCED operating point, which is optimized for

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import argparse
import asyncio import asyncio
import os import os
import time import time
@@ -44,6 +43,8 @@ from pipecat.processors.aggregators.openai_llm_context import (
) )
from pipecat.processors.filters.function_filter import FunctionFilter from pipecat.processors.filters.function_filter import FunctionFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.google.llm import GoogleLLMContext, GoogleLLMService from pipecat.services.google.llm import GoogleLLMContext, GoogleLLMService
from pipecat.sync.base_notifier import BaseNotifier from pipecat.sync.base_notifier import BaseNotifier

View File

@@ -63,7 +63,7 @@ async def run_bot(transport: BaseTransport):
) )
simli_ai = SimliVideoService( simli_ai = SimliVideoService(
SimliConfig(os.getenv("SIMLI_API_KEY"), os.getenv("SIMLI_FACE_ID")) SimliConfig(os.getenv("SIMLI_API_KEY"), os.getenv("SIMLI_FACE_ID")),
) )
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini") llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini")

View File

@@ -4,7 +4,6 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import os import os
import aiohttp import aiohttp

View File

@@ -2,70 +2,71 @@
This directory contains examples showing how to build voice and multimodal agents with Pipecat. Each example demonstrates specific features, progressing from basic to advanced concepts. This directory contains examples showing how to build voice and multimodal agents with Pipecat. Each example demonstrates specific features, progressing from basic to advanced concepts.
## Learning Paths ## Setup
Depending on what you're trying to build, these learning paths will guide you through relevant examples: 1. Make sure you have uv installed:
- **New to Pipecat**: Start with examples [01](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/01-say-one-thing.py), [02](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/02-llm-say-one-thing.py), [07](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/07-interruptible.py)
- **Building conversational bots**: [07](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/07-interruptible.py), [10](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/10-wake-phrase.py), [38](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/38-smart-turn-fal.py)
- **Common add-on capabilities**: [17](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/17-detect-user-idle.py), [24](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/24-stt-mute-filter.py), [28](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/28-transcription-processor.py), [34](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/34-audio-recording.py)
- **Adding visual capabilities**: [03](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/03-still-frame.py), [12](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/12a-describe-video-gemini-flash.py), [26](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/26c-gemini-multimodal-live-video.py)
- **Advanced agent capabilities**: [14](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/14-function-calling.py), [20](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/20a-persistent-context-openai.py), [37](https://github.com/pipecat-ai/pipecat/blob/main/examples/foundational/37-mem0.py)
## Quick Start
1. Set up a virtual environment:
```bash ```bash
python -m venv venv curl -LsSf https://astral.sh/uv/install.sh | sh
source venv/bin/activate # On Windows: venv\Scripts\activate
``` ```
2. Install dependencies: > **Need help?** Refer to the [uv install documentation](https://docs.astral.sh/uv/getting-started/installation/).
2. Create a venv and install example dependencies:
```bash ```bash
pip install -r requirements.txt uv sync --all-extras --no-extra krisp
``` ```
3. Create a `.env` file with your API keys. 3. Create a `.env` file with your API keys:
4. Run any example:
```bash ```bash
python 01-say-one-thing.py cp env.example .env
# Edit .env with your API keys
``` ```
5. Open the web interface at http://localhost:7860 and click "Connect" 4. Navigate to the examples directory:
```bash
cd examples/foundational
```
5. Run any example:
```bash
uv run python 01-say-one-thing.py
```
6. Open the web interface at http://localhost:7860/client/ and click "Connect"
## Running examples with other transports ## Running examples with other transports
It is possible to run most of the examples with other transports such as Twilio or Daily. Most examples support running with other transports, like Twilio or Daily.
### Daily ### Daily
You need to create a Daily account at https://dashboard.daily.co/u/signup. Once signed up, you can create your own room from the dashboard and set the environment variables `DAILY_SAMPLE_ROOM_URL` and `DAILY_API_KEY`. Alternatively, you can let the example create a room for you (still needs `DAILY_API_KEY` environment variable). Then, start any example with `-t daily`: You need to create a Daily account at https://dashboard.daily.co/u/signup. Once signed up, you can create your own room from the dashboard and set the environment variables `DAILY_SAMPLE_ROOM_URL` and `DAILY_API_KEY`. Alternatively, you can let the example create a room for you (still needs `DAILY_API_KEY` environment variable). Then, start any example with `-t daily`:
```bash ```bash
python 07-interruptible.py -t daily uv run 07-interruptible.py -t daily
``` ```
### Twilio ### Twilio
It is also possible to run the example through a Twilio phone number. You will It is also possible to run the example through a Twilio phone number. You will need to setup a few things:
need to setup a few things:
1. Install and run [ngrok](https://ngrok.com/download). 1. Install and run [ngrok](https://ngrok.com/download).
```bash ```bash
ngrok http 7860 ngrok http 7860
``` ```
2. Configure your Twilio phone number. One way is to setup a TwiML app and set the request URL to the ngrok URL from step (1). Then, set your phone number to use the new TwiML app. 2. Configure your Twilio phone number. One way is to setup a TwiML app and set the request URL to the ngrok URL from step (1). Then, set your phone number to use the new TwiML app.
Then, run the example with: Then, run the example with:
```bash ```bash
python 07-interruptible.py -t twilio -x NGROK_HOST_NAME (no protocol) uv run 07-interruptible.py -t twilio -x NGROK_HOST_NAME
``` ```
## Examples by Feature ## Examples by Feature
@@ -133,21 +134,18 @@ python 07-interruptible.py -t twilio -x NGROK_HOST_NAME (no protocol)
- **[16-gpu-container-local-bot.py](./16-gpu-container-local-bot.py)**: GPU-accelerated local bot (Performance measurement) - **[16-gpu-container-local-bot.py](./16-gpu-container-local-bot.py)**: GPU-accelerated local bot (Performance measurement)
### Utilities
## Advanced Usage ## Advanced Usage
### Customizing Network Settings ### Customizing Network Settings
```bash ```bash
python <example-name> --host 0.0.0.0 --port 8080 uv run python <example-name> --host 0.0.0.0 --port 8080
``` ```
### Troubleshooting ### Troubleshooting
- **No audio/video**: Check browser permissions for microphone and camera - **No audio/video**: Check browser permissions for microphone and camera
- **Connection errors**: Verify API keys in `.env` file - **Connection errors**: Verify API keys in `.env` file
- **Missing dependencies**: Run `pip install -r requirements.txt`
- **Port conflicts**: Use `--port` to change the port - **Port conflicts**: Use `--port` to change the port
For more examples, visit our [GitHub repository](https://github.com/pipecat-ai/pipecat/tree/main/examples). For more examples, visit our the [`pipecat-examples repository](https://github.com/pipecat-ai/pipecat-examples).

View File

@@ -1 +0,0 @@
pipecat-ai[webrtc,websocket,daily,deepgram,cartesia,silero,runner]>=0.0.77

View File

@@ -26,7 +26,14 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
print("🚀 Starting Pipecat bot...")
print("⏳ Loading AI models (30-40 seconds first run, <2 seconds after)\n")
logger.info("Loading Silero VAD model...")
from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer
logger.info("✅ Silero VAD model loaded")
logger.info("Loading pipeline components...")
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
@@ -37,8 +44,14 @@ from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
logger.info("✅ Pipeline components loaded")
logger.info("Loading WebRTC transport...")
from pipecat.transports.network.small_webrtc import SmallWebRTCTransport from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
logger.info("✅ All components loaded successfully!")
load_dotenv(override=True) load_dotenv(override=True)

View File

@@ -52,7 +52,7 @@ azure = [ "azure-cognitiveservices-speech~=1.42.0"]
cartesia = [ "cartesia~=2.0.3", "websockets>=13.1,<15.0" ] cartesia = [ "cartesia~=2.0.3", "websockets>=13.1,<15.0" ]
cerebras = [] cerebras = []
deepseek = [] deepseek = []
daily = [ "daily-python~=0.19.5" ] daily = [ "daily-python~=0.19.6" ]
deepgram = [ "deepgram-sdk~=4.7.0" ] deepgram = [ "deepgram-sdk~=4.7.0" ]
elevenlabs = [ "websockets>=13.1,<15.0" ] elevenlabs = [ "websockets>=13.1,<15.0" ]
fal = [ "fal-client~=0.5.9" ] fal = [ "fal-client~=0.5.9" ]

View File

@@ -32,7 +32,7 @@ also explains why it thinks the answer is valid or invalid.
To run the release evals: To run the release evals:
```sh ```sh
python run-release-evals.py -a -v uv run run-release-evals.py -a -v
``` ```
This runs all the evals and stores logs and audio (`-a`) for each test. This runs all the evals and stores logs and audio (`-a`) for each test.
@@ -41,7 +41,7 @@ You can also specify which tests to run. For example, to run all `07` series
tests: tests:
```sh ```sh
python run-release-evals.py -p 07 -a -v uv run run-release-evals.py -p 07 -a -v
``` ```
## Script Evals ## Script Evals
@@ -49,7 +49,7 @@ python run-release-evals.py -p 07 -a -v
You can also run evals for a single example (not part of the release set): You can also run evals for a single example (not part of the release set):
```sh ```sh
python run-eval.py -p "A simple math addition" -a -v YOUR_EXAMPLE_SCRIPT uv run run-eval.py -p "A simple math addition" -a -v YOUR_EXAMPLE_SCRIPT
``` ```
Your script needs to follow any of the foundation examples pattern. Your script needs to follow any of the foundation examples pattern.

View File

@@ -176,7 +176,7 @@ async def run_example_pipeline(script_path: Path):
), ),
) )
await module.run_example(transport, argparse.Namespace(), True) await module.run_bot(transport)
async def run_eval_pipeline( async def run_eval_pipeline(

View File

@@ -102,6 +102,10 @@ TESTS_19 = [
("19a-azure-realtime-beta.py", PROMPT_WEATHER, EVAL_WEATHER), ("19a-azure-realtime-beta.py", PROMPT_WEATHER, EVAL_WEATHER),
] ]
TESTS_21 = [
("21a-tavus-video-service.py", PROMPT_SIMPLE_MATH, None),
]
TESTS_26 = [ TESTS_26 = [
("26-gemini-multimodal-live.py", PROMPT_SIMPLE_MATH, None), ("26-gemini-multimodal-live.py", PROMPT_SIMPLE_MATH, None),
("26a-gemini-multimodal-live-transcription.py", PROMPT_SIMPLE_MATH, None), ("26a-gemini-multimodal-live-transcription.py", PROMPT_SIMPLE_MATH, None),
@@ -112,16 +116,27 @@ TESTS_26 = [
# ("26d-gemini-multimodal-live-text.py", PROMPT_SIMPLE_MATH, None), # ("26d-gemini-multimodal-live-text.py", PROMPT_SIMPLE_MATH, None),
] ]
TESTS_27 = [
("27-simli-layer.py", PROMPT_SIMPLE_MATH, None),
]
TESTS_40 = [ TESTS_40 = [
("40-aws-nova-sonic.py", PROMPT_SIMPLE_MATH, None), ("40-aws-nova-sonic.py", PROMPT_SIMPLE_MATH, None),
] ]
TESTS_43 = [
("43a-heygen-video-service.py", PROMPT_SIMPLE_MATH, None),
]
TESTS = [ TESTS = [
*TESTS_07, *TESTS_07,
*TESTS_14, *TESTS_14,
*TESTS_19, *TESTS_19,
*TESTS_21,
*TESTS_26, *TESTS_26,
*TESTS_27,
*TESTS_40, *TESTS_40,
*TESTS_43,
] ]

View File

@@ -7,11 +7,14 @@
"""Observer for measuring user-to-bot response latency.""" """Observer for measuring user-to-bot response latency."""
import time import time
from statistics import mean
from loguru import logger from loguru import logger
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStartedSpeakingFrame, BotStartedSpeakingFrame,
CancelFrame,
EndFrame,
UserStartedSpeakingFrame, UserStartedSpeakingFrame,
UserStoppedSpeakingFrame, UserStoppedSpeakingFrame,
) )
@@ -35,6 +38,7 @@ class UserBotLatencyLogObserver(BaseObserver):
super().__init__() super().__init__()
self._processed_frames = set() self._processed_frames = set()
self._user_stopped_time = 0 self._user_stopped_time = 0
self._latencies = []
async def on_push_frame(self, data: FramePushed): async def on_push_frame(self, data: FramePushed):
"""Process frames to track speech timing and calculate latency. """Process frames to track speech timing and calculate latency.
@@ -56,6 +60,18 @@ class UserBotLatencyLogObserver(BaseObserver):
self._user_stopped_time = 0 self._user_stopped_time = 0
elif isinstance(data.frame, UserStoppedSpeakingFrame): elif isinstance(data.frame, UserStoppedSpeakingFrame):
self._user_stopped_time = time.time() self._user_stopped_time = time.time()
elif isinstance(data.frame, (EndFrame, CancelFrame)):
if self._latencies:
avg_latency = mean(self._latencies)
min_latency = min(self._latencies)
max_latency = max(self._latencies)
logger.info(
f"⏱️ LATENCY FROM USER STOPPED SPEAKING TO BOT STARTED SPEAKING - Avg: {avg_latency:.3f}s, Min: {min_latency:.3f}s, Max: {max_latency:.3f}s"
)
elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time: elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time:
latency = time.time() - self._user_stopped_time latency = time.time() - self._user_stopped_time
logger.debug(f"⏱️ LATENCY FROM USER STOPPED SPEAKING TO BOT STARTED SPEAKING: {latency}") self._user_stopped_time = 0
self._latencies.append(latency)
logger.debug(
f"⏱️ LATENCY FROM USER STOPPED SPEAKING TO BOT STARTED SPEAKING: {latency:.3f}s"
)

View File

@@ -153,22 +153,23 @@ class TaskObserver(BaseObserver):
async def _proxy_task_handler(self, queue: asyncio.Queue, observer: BaseObserver): async def _proxy_task_handler(self, queue: asyncio.Queue, observer: BaseObserver):
"""Handle frame processing for a single observer.""" """Handle frame processing for a single observer."""
warning_reported = False on_push_frame_deprecated = False
signature = inspect.signature(observer.on_push_frame)
if len(signature.parameters) > 1:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Observer `on_push_frame(source, destination, frame, direction, timestamp)` is deprecated, us `on_push_frame(data: FramePushed)` instead.",
DeprecationWarning,
)
on_push_frame_deprecated = True
while True: while True:
data = await queue.get() data = await queue.get()
if on_push_frame_deprecated:
signature = inspect.signature(observer.on_push_frame)
if len(signature.parameters) > 1:
if not warning_reported:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Observer `on_push_frame(source, destination, frame, direction, timestamp)` is deprecated, us `on_push_frame(data: FramePushed)` instead.",
DeprecationWarning,
)
warning_reported = True
await observer.on_push_frame( await observer.on_push_frame(
data.src, data.dst, data.frame, data.direction, data.timestamp data.src, data.dst, data.frame, data.direction, data.timestamp
) )

View File

@@ -7,17 +7,14 @@
"""Daily room and token configuration utilities. """Daily room and token configuration utilities.
This module provides helper functions for creating and configuring Daily rooms This module provides helper functions for creating and configuring Daily rooms
and authentication tokens. It handles both command-line argument parsing and and authentication tokens. It automatically creates temporary rooms for
environment variable configuration. development or uses existing rooms specified via environment variables.
The module supports creating temporary rooms for development or using existing Environment variables:
rooms specified via arguments or environment variables.
Required environment variables: - DAILY_API_KEY - Daily API key for room/token creation (required)
- DAILY_SAMPLE_ROOM_URL (optional) - Existing room URL to use. If not provided,
- DAILY_API_KEY - Daily API key for room/token creation a temporary room will be created automatically.
- DAILY_SAMPLE_ROOM_URL (optional) - Existing room URL to use
- DAILY_SAMPLE_ROOM_TOKEN (optional) - Existing token to use
Example:: Example::
@@ -29,17 +26,26 @@ Example::
# Use room_url and token with DailyTransport # Use room_url and token with DailyTransport
""" """
import argparse
import os import os
from typing import Optional import time
import uuid
from typing import Tuple
import aiohttp import aiohttp
from pipecat.transports.services.helpers.daily_rest import DailyRESTHelper from pipecat.transports.services.helpers.daily_rest import (
DailyRESTHelper,
DailyRoomParams,
DailyRoomProperties,
)
async def configure(aiohttp_session: aiohttp.ClientSession): async def configure(aiohttp_session: aiohttp.ClientSession) -> Tuple[str, str]:
"""Configure Daily room URL and token from arguments or environment. """Configure Daily room URL and token from environment variables.
This function will either:
1. Use an existing room URL from DAILY_SAMPLE_ROOM_URL environment variable
2. Create a new temporary room automatically if no URL is provided
Args: Args:
aiohttp_session: HTTP session for making API requests. aiohttp_session: HTTP session for making API requests.
@@ -48,65 +54,79 @@ async def configure(aiohttp_session: aiohttp.ClientSession):
Tuple containing the room URL and authentication token. Tuple containing the room URL and authentication token.
Raises: Raises:
Exception: If room URL or API key are not provided. Exception: If DAILY_API_KEY is not provided in environment variables.
""" """
(url, token, _) = await configure_with_args(aiohttp_session) # Check for required API key
return (url, token) api_key = os.getenv("DAILY_API_KEY")
if not api_key:
async def configure_with_args(
aiohttp_session: aiohttp.ClientSession, parser: Optional[argparse.ArgumentParser] = None
):
"""Configure Daily room with command-line argument parsing.
Args:
aiohttp_session: HTTP session for making API requests.
parser: Optional argument parser. If None, creates a default one.
Returns:
Tuple containing room URL, authentication token, and parsed arguments.
Raises:
Exception: If room URL or API key are not provided via arguments or environment.
"""
if not parser:
parser = argparse.ArgumentParser(description="Daily AI SDK Bot Sample")
parser.add_argument(
"-u", "--url", type=str, required=False, help="URL of the Daily room to join"
)
parser.add_argument(
"-k",
"--apikey",
type=str,
required=False,
help="Daily API Key (needed to create an owner token for the room)",
)
args, unknown = parser.parse_known_args()
url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL")
key = args.apikey or os.getenv("DAILY_API_KEY")
if not url:
raise Exception( raise Exception(
"No Daily room specified. use the -u/--url option from the command line, or set DAILY_SAMPLE_ROOM_URL in your environment to specify a Daily room URL." "DAILY_API_KEY environment variable is required. "
"Get your API key from https://dashboard.daily.co/developers"
) )
if not key: # Check for existing room URL
raise Exception( existing_room_url = os.getenv("DAILY_SAMPLE_ROOM_URL")
"No Daily API key specified. use the -k/--apikey option from the command line, or set DAILY_API_KEY in your environment to specify a Daily API key, available from https://dashboard.daily.co/developers."
)
daily_rest_helper = DailyRESTHelper( daily_rest_helper = DailyRESTHelper(
daily_api_key=key, daily_api_key=api_key,
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"), daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
aiohttp_session=aiohttp_session, aiohttp_session=aiohttp_session,
) )
# Create a meeting token for the given room with an expiration 2 hours in if existing_room_url:
# the future. # Use existing room
print(f"Using existing Daily room: {existing_room_url}")
room_url = existing_room_url
else:
# Create a new temporary room
room_name = f"pipecat-{uuid.uuid4().hex[:8]}"
print(f"Creating new Daily room: {room_name}")
# Calculate expiration time: current time + 2 hours
expiration_time = time.time() + (2 * 60 * 60) # 2 hours from now
# Create room properties with absolute timestamp
room_properties = DailyRoomProperties(
exp=expiration_time, # Absolute Unix timestamp
eject_at_room_exp=True,
)
# Create room parameters
room_params = DailyRoomParams(name=room_name, properties=room_properties)
room_response = await daily_rest_helper.create_room(room_params)
room_url = room_response.url
print(f"Created Daily room: {room_url}")
# Create a meeting token for the room with an expiration 2 hours in the future
expiry_time: float = 2 * 60 * 60 expiry_time: float = 2 * 60 * 60
token = await daily_rest_helper.get_token(room_url, expiry_time)
token = await daily_rest_helper.get_token(url, expiry_time) return (room_url, token)
return (url, token, args)
# Keep this for backwards compatibility, but mark as deprecated
async def configure_with_args(aiohttp_session: aiohttp.ClientSession, parser=None):
"""Configure Daily room with command-line argument parsing.
.. deprecated:: 0.0.78
This function is deprecated. Use configure() instead which uses
environment variables only.
Args:
aiohttp_session: HTTP session for making API requests.
parser: Ignored. Kept for backwards compatibility.
Returns:
Tuple containing room URL, authentication token, and None (for args).
"""
import warnings
warnings.warn(
"configure_with_args is deprecated. Use configure() instead.",
DeprecationWarning,
stacklevel=2,
)
room_url, token = await configure(aiohttp_session)
return (room_url, token, None)

View File

@@ -82,7 +82,7 @@ from pipecat.runner.types import (
try: try:
import uvicorn import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, WebSocket from fastapi import BackgroundTasks, FastAPI, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
except ImportError as e: except ImportError as e:
@@ -261,17 +261,43 @@ def _setup_daily_routes(app: FastAPI):
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
room_url, token = await configure(session) room_url, token = await configure(session)
# Start the bot in the background # Start the bot in the background with empty body for GET requests
bot_module = _get_bot_module() bot_module = _get_bot_module()
runner_args = DailyRunnerArguments(room_url=room_url, token=token, body={}) runner_args = DailyRunnerArguments(room_url=room_url, token=token, body={})
asyncio.create_task(bot_module.bot(runner_args)) asyncio.create_task(bot_module.bot(runner_args))
return RedirectResponse(room_url) return RedirectResponse(room_url)
@app.post("/connect") async def _handle_rtvi_request(request: Request):
async def rtvi_connect(): """Common handler for both /start and /connect endpoints.
"""Launch a Daily bot and return connection info for RTVI clients."""
Expects POST body like::
{
"createDailyRoom": true,
"dailyRoomProperties": { "start_video_off": true },
"body": { "custom_data": "value" }
}
"""
print("Starting bot with Daily transport") print("Starting bot with Daily transport")
# Parse the request body
try:
request_data = await request.json()
logger.debug(f"Received request: {request_data}")
except Exception as e:
logger.error(f"Failed to parse request body: {e}")
request_data = {}
# Extract the body data that should be passed to the bot
# This mimics Pipecat Cloud's behavior
bot_body = request_data.get("body", {})
# Log the extracted body data for debugging
if bot_body:
logger.info(f"Extracted body data for bot: {bot_body}")
else:
logger.debug("No body data provided in request")
import aiohttp import aiohttp
from pipecat.runner.daily import configure from pipecat.runner.daily import configure
@@ -279,11 +305,30 @@ def _setup_daily_routes(app: FastAPI):
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
room_url, token = await configure(session) room_url, token = await configure(session)
# Start the bot in the background # Start the bot in the background with extracted body data
bot_module = _get_bot_module() bot_module = _get_bot_module()
runner_args = DailyRunnerArguments(room_url=room_url, token=token, body={}) runner_args = DailyRunnerArguments(room_url=room_url, token=token, body=bot_body)
asyncio.create_task(bot_module.bot(runner_args)) asyncio.create_task(bot_module.bot(runner_args))
return {"room_url": room_url, "token": token} # Match PCC /start endpoint response format:
return {"dailyRoom": room_url, "dailyToken": token}
@app.post("/start")
async def rtvi_start(request: Request):
"""Launch a Daily bot and return connection info for RTVI clients."""
return await _handle_rtvi_request(request)
@app.post("/connect")
async def rtvi_connect(request: Request):
"""Launch a Daily bot and return connection info for RTVI clients.
.. deprecated:: 0.0.78
Use /start instead. This endpoint will be removed in a future version.
"""
logger.warning(
"DEPRECATED: /connect endpoint is deprecated. Please use /start instead. "
"This endpoint will be removed in a future version."
)
return await _handle_rtvi_request(request)
def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str): def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str):
@@ -345,6 +390,7 @@ async def _run_daily_direct():
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
room_url, token = await configure(session) room_url, token = await configure(session)
# Direct connections have no request body, so use empty dict
runner_args = DailyRunnerArguments(room_url=room_url, token=token, body={}) runner_args = DailyRunnerArguments(room_url=room_url, token=token, body={})
# Get the bot module and run it directly # Get the bot module and run it directly
@@ -357,6 +403,27 @@ async def _run_daily_direct():
await bot_module.bot(runner_args) await bot_module.bot(runner_args)
def _validate_and_clean_proxy(proxy: str) -> str:
"""Validate and clean proxy hostname, removing protocol if present."""
if not proxy:
return proxy
original_proxy = proxy
# Strip common protocols
if proxy.startswith(("http://", "https://")):
proxy = proxy.split("://", 1)[1]
logger.warning(
f"Removed protocol from proxy URL. Using '{proxy}' instead of '{original_proxy}'. "
f"The --proxy argument expects only the hostname (e.g., 'mybot.ngrok.io')."
)
# Remove trailing slashes
proxy = proxy.rstrip("/")
return proxy
def main(): def main():
"""Start the Pipecat development runner. """Start the Pipecat development runner.
@@ -408,6 +475,10 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Validate and clean proxy hostname
if args.proxy:
args.proxy = _validate_and_clean_proxy(args.proxy)
# Auto-set transport to daily if --direct is used without explicit transport # Auto-set transport to daily if --direct is used without explicit transport
if args.direct and args.transport == "webrtc": # webrtc is the default if args.direct and args.transport == "webrtc": # webrtc is the default
args.transport = "daily" args.transport = "daily"
@@ -438,17 +509,16 @@ def main():
if args.transport == "webrtc": if args.transport == "webrtc":
print() print()
if args.esp32: if args.esp32:
print( print(f"🚀 Bot ready! (ESP32 mode)")
f"🚀 WebRTC server starting at http://{args.host}:{args.port}/client (ESP32 mode)" print(f" → Open http://{args.host}:{args.port}/client in your browser")
)
else: else:
print(f"🚀 WebRTC server starting at http://{args.host}:{args.port}/client") print(f"🚀 Bot ready!")
print(f" Open this URL in your browser to connect!") print(f" Open http://{args.host}:{args.port}/client in your browser")
print() print()
elif args.transport == "daily": elif args.transport == "daily":
print() print()
print(f"🚀 Daily server starting at http://{args.host}:{args.port}") print(f"🚀 Bot ready!")
print(f" Open this URL in your browser to start a session!") print(f" Open http://{args.host}:{args.port} in your browser to start a session")
print() print()
# Create the app with transport-specific setup # Create the app with transport-specific setup

View File

@@ -34,6 +34,10 @@ from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
from pipecat.utils.text.skip_tags_aggregator import SkipTagsAggregator from pipecat.utils.text.skip_tags_aggregator import SkipTagsAggregator
from pipecat.utils.tracing.service_decorators import traced_tts from pipecat.utils.tracing.service_decorators import traced_tts
# Suppress regex warnings from pydub (used by cartesia)
warnings.filterwarnings("ignore", message="invalid escape sequence", category=SyntaxWarning)
# See .env.example for Cartesia configuration needed # See .env.example for Cartesia configuration needed
try: try:
from cartesia import AsyncCartesia from cartesia import AsyncCartesia

View File

@@ -983,17 +983,14 @@ class GeminiMultimodalLiveLLMService(LLMService):
with audio and video inputs, preventing temporal misalignment that can occur with audio and video inputs, preventing temporal misalignment that can occur
when different modalities are processed through separate API pathways. when different modalities are processed through separate API pathways.
After sending the text, we signal turn completion to trigger a model response For realtimeInput, turn completion is automatically inferred by the API based
for text-only interactions. on user activity, so no explicit turnComplete signal is needed.
Args: Args:
text: The text to send as user input. text: The text to send as user input.
""" """
evt = events.TextInputMessage.from_text(text) evt = events.TextInputMessage.from_text(text)
await self.send_client_event(evt) await self.send_client_event(evt)
# After sending text, we need to signal that the turn is complete.
evt = events.ClientContentMessage.model_validate({"clientContent": {"turnComplete": True}})
await self.send_client_event(evt)
async def _send_user_video(self, frame): async def _send_user_video(self, frame):
"""Send user video frame to Gemini Live API.""" """Send user video frame to Gemini Live API."""

View File

@@ -25,7 +25,6 @@ from loguru import logger
from pipecat.adapters.base_llm_adapter import BaseLLMAdapter from pipecat.adapters.base_llm_adapter import BaseLLMAdapter
from pipecat.adapters.schemas.direct_function import DirectFunction, DirectFunctionWrapper from pipecat.adapters.schemas.direct_function import DirectFunction, DirectFunctionWrapper
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
from pipecat.frames.frames import ( from pipecat.frames.frames import (
CancelFrame, CancelFrame,
@@ -108,6 +107,7 @@ class FunctionCallRegistryItem:
function_name: Optional[str] function_name: Optional[str]
handler: FunctionCallHandler | "DirectFunctionWrapper" handler: FunctionCallHandler | "DirectFunctionWrapper"
cancel_on_interruption: bool cancel_on_interruption: bool
handler_deprecated: bool
@dataclass @dataclass
@@ -282,12 +282,25 @@ class LLMService(AIService):
cancel_on_interruption: Whether to cancel this function call when an cancel_on_interruption: Whether to cancel this function call when an
interruption occurs. Defaults to True. interruption occurs. Defaults to True.
""" """
signature = inspect.signature(handler)
handler_deprecated = len(signature.parameters) > 1
if handler_deprecated:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Function calls with parameters `(function_name, tool_call_id, arguments, llm, context, result_callback)` are deprecated, use a single `FunctionCallParams` parameter instead.",
DeprecationWarning,
)
# Registering a function with the function_name set to None will run # Registering a function with the function_name set to None will run
# that handler for all functions # that handler for all functions
self._functions[function_name] = FunctionCallRegistryItem( self._functions[function_name] = FunctionCallRegistryItem(
function_name=function_name, function_name=function_name,
handler=handler, handler=handler,
cancel_on_interruption=cancel_on_interruption, cancel_on_interruption=cancel_on_interruption,
handler_deprecated=handler_deprecated,
) )
# Start callbacks are now deprecated. # Start callbacks are now deprecated.
@@ -325,6 +338,7 @@ class LLMService(AIService):
function_name=wrapper.name, function_name=wrapper.name,
handler=wrapper, handler=wrapper,
cancel_on_interruption=cancel_on_interruption, cancel_on_interruption=cancel_on_interruption,
handler_deprecated=False,
) )
def unregister_function(self, function_name: Optional[str]): def unregister_function(self, function_name: Optional[str]):
@@ -552,17 +566,7 @@ class LLMService(AIService):
) )
else: else:
# Handler is a FunctionCallHandler # Handler is a FunctionCallHandler
signature = inspect.signature(item.handler) if item.handler_deprecated:
if len(signature.parameters) > 1:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Function calls with parameters `(function_name, tool_call_id, arguments, llm, context, result_callback)` are deprecated, use a single `FunctionCallParams` parameter instead.",
DeprecationWarning,
)
await item.handler( await item.handler(
runner_item.function_name, runner_item.function_name,
runner_item.tool_call_id, runner_item.tool_call_id,

View File

@@ -84,7 +84,9 @@ class PiperTTSService(TTSService):
try: try:
await self.start_ttfb_metrics() await self.start_ttfb_metrics()
async with self._session.post(self._base_url, json=text, headers=headers) as response: async with self._session.post(
self._base_url, json={"text": text}, headers=headers
) as response:
if response.status != 200: if response.status != 200:
error = await response.text() error = await response.text()
logger.error( logger.error(

View File

@@ -18,6 +18,8 @@ from pipecat.frames.frames import (
OutputImageRawFrame, OutputImageRawFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSAudioRawFrame, TTSAudioRawFrame,
TTSStoppedFrame,
UserStartedSpeakingFrame,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, StartFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, StartFrame
from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator from pipecat.utils.asyncio.watchdog_async_iterator import WatchdogAsyncIterator
@@ -45,24 +47,39 @@ class SimliVideoService(FrameProcessor):
simli_config: SimliConfig, simli_config: SimliConfig,
use_turn_server: bool = False, use_turn_server: bool = False,
latency_interval: int = 0, latency_interval: int = 0,
simli_url: str = "https://api.simli.ai",
is_trinity_avatar: bool = False,
): ):
"""Initialize the Simli video service. """Initialize the Simli video service.
Args: Args:
simli_config: Configuration object for Simli client settings. simli_config: Configuration object for Simli client settings.
use_turn_server: Whether to use TURN server for connection. Defaults to False. use_turn_server: Whether to use TURN server for connection. Defaults to False.
latency_interval: Latency interval setting for video processing. Defaults to 0. 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 of enterprise users.
is_trinity_avatar: boolean to tell simli client that this is a Trinity avatar which reduces latency when using Trinity.
""" """
super().__init__() super().__init__()
self._simli_client = SimliClient(simli_config, use_turn_server, latency_interval) self._initialized = False
simli_config.maxIdleTime += 5
simli_config.maxSessionLength += 5
self._simli_client = SimliClient(
simli_config,
use_turn_server,
latency_interval,
simliURL=simli_url,
)
self._pipecat_resampler_event = asyncio.Event()
self._pipecat_resampler: AudioResampler = None self._pipecat_resampler: AudioResampler = None
self._pipecat_resampler_event = asyncio.Event()
self._simli_resampler = AudioResampler("s16", "mono", 16000) self._simli_resampler = AudioResampler("s16", "mono", 16000)
self._initialized = False
self._audio_task: asyncio.Task = None self._audio_task: asyncio.Task = None
self._video_task: asyncio.Task = None self._video_task: asyncio.Task = None
self._is_trinity_avatar = is_trinity_avatar
self._previously_interrupted = is_trinity_avatar
self._audio_buffer = bytearray()
async def _start_connection(self): async def _start_connection(self):
"""Start the connection to Simli service and begin processing tasks.""" """Start the connection to Simli service and begin processing tasks."""
@@ -71,11 +88,9 @@ class SimliVideoService(FrameProcessor):
self._initialized = True self._initialized = True
# Create task to consume and process audio and video # Create task to consume and process audio and video
if not self._audio_task: await self._simli_client.sendSilence()
self._audio_task = self.create_task(self._consume_and_process_audio()) self._audio_task = self.create_task(self._consume_and_process_audio())
self._video_task = self.create_task(self._consume_and_process_video())
if not self._video_task:
self._video_task = self.create_task(self._consume_and_process_video())
async def _consume_and_process_audio(self): async def _consume_and_process_audio(self):
"""Consume audio frames from Simli and push them downstream.""" """Consume audio frames from Simli and push them downstream."""
@@ -118,7 +133,6 @@ class SimliVideoService(FrameProcessor):
""" """
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
if isinstance(frame, StartFrame): if isinstance(frame, StartFrame):
await self.push_frame(frame, direction)
await self._start_connection() await self._start_connection()
elif isinstance(frame, TTSAudioRawFrame): elif isinstance(frame, TTSAudioRawFrame):
# Send audio frame to Simli # Send audio frame to Simli
@@ -137,19 +151,41 @@ class SimliVideoService(FrameProcessor):
resampled_frames = self._simli_resampler.resample(old_frame) resampled_frames = self._simli_resampler.resample(old_frame)
for resampled_frame in resampled_frames: for resampled_frame in resampled_frames:
await self._simli_client.send( audioBytes = resampled_frame.to_ndarray().astype(np.int16).tobytes()
resampled_frame.to_ndarray().astype(np.int16).tobytes() if self._previously_interrupted:
) self._audio_buffer.extend(audioBytes)
if len(self._audio_buffer) >= 128000:
try:
for flushFrame in self._simli_resampler.resample(None):
self._audio_buffer.extend(
flushFrame.to_ndarray().astype(np.int16).tobytes()
)
finally:
await self._simli_client.playImmediate(self._audio_buffer)
self._previously_interrupted = False
self._audio_buffer = bytearray()
else:
await self._simli_client.send(audioBytes)
return
except Exception as e: except Exception as e:
logger.exception(f"{self} exception: {e}") logger.exception(f"{self} exception: {e}")
elif isinstance(frame, TTSStoppedFrame):
try:
if self._previously_interrupted and len(self._audio_buffer) > 0:
await self._simli_client.playImmediate(self._audio_buffer)
self._previously_interrupted = False
self._audio_buffer = bytearray()
except Exception as e:
logger.exception(f"{self} exception: {e}")
return
elif isinstance(frame, (EndFrame, CancelFrame)): elif isinstance(frame, (EndFrame, CancelFrame)):
await self._stop() await self._stop()
await self.push_frame(frame, direction) elif isinstance(frame, (StartInterruptionFrame, UserStartedSpeakingFrame)):
elif isinstance(frame, StartInterruptionFrame): if not self._previously_interrupted:
await self._simli_client.clearBuffer() await self._simli_client.clearBuffer()
await self.push_frame(frame, direction) self._previously_interrupted = self._is_trinity_avatar
else:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
async def _stop(self): async def _stop(self):
"""Stop the Simli client and cancel processing tasks.""" """Stop the Simli client and cancel processing tasks."""

View File

@@ -191,7 +191,7 @@ class SpeakerFragments:
passive_format = active_format passive_format = active_format
return { return {
"text": self._format_text(active_format if self.is_active else passive_format), "text": self._format_text(active_format if self.is_active else passive_format),
"user_id": self.speaker_id, "user_id": self.speaker_id or "",
"timestamp": self.timestamp, "timestamp": self.timestamp,
"language": self.language, "language": self.language,
"result": [frag.result for frag in self.fragments], "result": [frag.result for frag in self.fragments],

View File

@@ -24,6 +24,7 @@ from pipecat.frames.frames import (
Frame, Frame,
OutputAudioRawFrame, OutputAudioRawFrame,
OutputImageRawFrame, OutputImageRawFrame,
OutputTransportReadyFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSAudioRawFrame, TTSAudioRawFrame,
@@ -81,6 +82,7 @@ class TavusVideoService(AIService):
self._send_task: Optional[asyncio.Task] = None self._send_task: Optional[asyncio.Task] = None
# This is the custom track destination expected by Tavus # This is the custom track destination expected by Tavus
self._transport_destination: Optional[str] = "stream" self._transport_destination: Optional[str] = "stream"
self._transport_ready = False
async def setup(self, setup: FrameProcessorSetup): async def setup(self, setup: FrameProcessorSetup):
"""Set up the Tavus video service. """Set up the Tavus video service.
@@ -145,7 +147,8 @@ class TavusVideoService(AIService):
format=video_frame.color_format, format=video_frame.color_format,
) )
frame.transport_source = video_source frame.transport_source = video_source
await self.push_frame(frame) if self._transport_ready:
await self.push_frame(frame)
async def _on_participant_audio_data( async def _on_participant_audio_data(
self, participant_id: str, audio: AudioData, audio_source: str self, participant_id: str, audio: AudioData, audio_source: str
@@ -157,7 +160,8 @@ class TavusVideoService(AIService):
num_channels=audio.num_channels, num_channels=audio.num_channels,
) )
frame.transport_source = audio_source frame.transport_source = audio_source
await self.push_frame(frame) if self._transport_ready:
await self.push_frame(frame)
def can_generate_metrics(self) -> bool: def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics. """Check if this service can generate processing metrics.
@@ -221,6 +225,9 @@ class TavusVideoService(AIService):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, TTSAudioRawFrame): elif isinstance(frame, TTSAudioRawFrame):
await self._handle_audio_frame(frame) await self._handle_audio_frame(frame)
elif isinstance(frame, OutputTransportReadyFrame):
self._transport_ready = True
await self.push_frame(frame, direction)
else: else:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)

View File

@@ -245,6 +245,10 @@ class TavusTransportClient:
on_recording_started=partial(self._on_handle_callback, "on_recording_started"), on_recording_started=partial(self._on_handle_callback, "on_recording_started"),
on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"), on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"),
on_recording_error=partial(self._on_handle_callback, "on_recording_error"), on_recording_error=partial(self._on_handle_callback, "on_recording_error"),
on_transcription_stopped=partial(
self._on_handle_callback, "on_transcription_stopped"
),
on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"),
) )
self._client = DailyTransportClient( self._client = DailyTransportClient(
room_url, None, "Pipecat", self._params, daily_callbacks, self._bot_name room_url, None, "Pipecat", self._params, daily_callbacks, self._bot_name

12
uv.lock generated
View File

@@ -1185,13 +1185,13 @@ wheels = [
[[package]] [[package]]
name = "daily-python" name = "daily-python"
version = "0.19.5" version = "0.19.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/ca/683c8a729b43a6e0ac4296973908be1c9cb0956bca69ecd6e5e4c4d56015/daily_python-0.19.5-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:a1c3e70a4dd87d0a829ebbb657c87cd20737246ffe1bc8351010cef8cdb34a52", size = 13686352, upload-time = "2025-07-30T20:19:03.658Z" }, { url = "https://files.pythonhosted.org/packages/4a/73/74a313e6557a6f4da153b38f3feb5b9e4012268322976c79495ce2f228ec/daily_python-0.19.6-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:d35d181f9ba824b62ca531449b237dc9aa771e69c00205a6efa359c371208ee6", size = 13687145, upload-time = "2025-08-02T01:56:51.197Z" },
{ url = "https://files.pythonhosted.org/packages/90/6e/a0e735021a27e81d2f3d26a2664886e06608cda50bd71ddd3d111be39c72/daily_python-0.19.5-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:98faf21a04bd29086245319e3a4df0ba164f0d0224fc4f8278365e70d927ed45", size = 12018783, upload-time = "2025-07-30T20:19:05.921Z" }, { url = "https://files.pythonhosted.org/packages/bb/ad/fe2f81fc516851200ffec75f8ce44f631b85c9bc5bbdf9f6637f0823e99a/daily_python-0.19.6-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:826342ee863086bb7192230615e26920700706ea79824f7028305f084e18d2e9", size = 12045258, upload-time = "2025-08-02T01:56:53.38Z" },
{ url = "https://files.pythonhosted.org/packages/8c/60/454e0f7efe7086411bf8a679caf866f036d6b51a68154f5d1cdd22933bfd/daily_python-0.19.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9021e012ec3a39faea1afd4af2c1744e2c67e3a5d397358529b375a7c917f756", size = 14055444, upload-time = "2025-07-30T20:19:08.043Z" }, { url = "https://files.pythonhosted.org/packages/22/0e/52aa355208d015134c88055ed53eed83452204590ac20ee2dcd1d6b4fa95/daily_python-0.19.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ff87c59e677c04c33e46963517e566a77fba425e9d52048418db4e037c6a1d6", size = 14066192, upload-time = "2025-08-02T01:56:55.669Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/cc4e3d04aef2a84a48ff9c0a8c5e58d0fd617b3ebf0cd9c8f0ab73cd6346/daily_python-0.19.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b64d67f513097004d96796562a46020ff982c7633985be124faf59a4157344b0", size = 14568186, upload-time = "2025-07-30T20:19:10.196Z" }, { url = "https://files.pythonhosted.org/packages/32/a0/cab8d478c5ae576bc607431a4d8d9e5a0aae831c096cf833ba2aa1535501/daily_python-0.19.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d15bf8657105a92ddbc88da4dd5bded88075cdf2bc33552406b93f586fdaeced", size = 14575791, upload-time = "2025-08-02T01:56:57.983Z" },
] ]
[[package]] [[package]]
@@ -4200,7 +4200,7 @@ requires-dist = [
{ name = "azure-cognitiveservices-speech", marker = "extra == 'azure'", specifier = "~=1.42.0" }, { name = "azure-cognitiveservices-speech", marker = "extra == 'azure'", specifier = "~=1.42.0" },
{ name = "cartesia", marker = "extra == 'cartesia'", specifier = "~=2.0.3" }, { name = "cartesia", marker = "extra == 'cartesia'", specifier = "~=2.0.3" },
{ name = "coremltools", marker = "extra == 'local-smart-turn'", specifier = ">=8.0" }, { name = "coremltools", marker = "extra == 'local-smart-turn'", specifier = ">=8.0" },
{ name = "daily-python", marker = "extra == 'daily'", specifier = "~=0.19.5" }, { name = "daily-python", marker = "extra == 'daily'", specifier = "~=0.19.6" },
{ name = "deepgram-sdk", marker = "extra == 'deepgram'", specifier = "~=4.7.0" }, { name = "deepgram-sdk", marker = "extra == 'deepgram'", specifier = "~=4.7.0" },
{ name = "docstring-parser", specifier = "~=0.16" }, { name = "docstring-parser", specifier = "~=0.16" },
{ name = "einops", marker = "extra == 'moondream'", specifier = "~=0.8.0" }, { name = "einops", marker = "extra == 'moondream'", specifier = "~=0.8.0" },