Merge branch 'main' into groundingMetadata

This commit is contained in:
Pete
2025-07-20 19:54:30 -04:00
committed by GitHub
105 changed files with 8529 additions and 4312 deletions

View File

@@ -4,5 +4,5 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
language_version: python3 language_version: python3
args: [ --select, I, ] args: [--fix]
- id: ruff-format - id: ruff-format

View File

@@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Added `LocalSmartTurnAnalyzerV2`, which supports local on-device inference
with the new `smart-turn-v2` turn detection model.
### Changed
- For `LmntTTSService`, changed the default `model` to `blizzard`, LMNT's
recommended model.
### Fixed
- Fixed an issue where, in some edge cases, the `EmulateUserStartedSpeakingFrame`
could be created even if we didn't have a transcription.
- Fixed an issue in `GoogleLLMContext` where it would inject the
`system_message` as a "user" message into cases where it was not meant to;
it was only meant to do that when there were no "regular" (non-function-call)
messages in the context, to ensure that inference would run properly.
## [0.0.76] - 2025-07-11
### Added
- Added `SpeechControlParamsFrame`, a new `SystemFrame` that notifies
downstream processors of the VAD and Turn analyzer params. This frame is
pushed by the `BaseInputTransport` at Start and any time a
`VADParamsUpdateFrame` is received.
### Changed
- Two package dependencies have been updated:
- `numpy` now supports 1.26.0 and newer
- `transformers` now supports 4.48.0 and newer
### Fixed
- Fixed an issue with RTVI's handling of `append-to-context`.
- Fixed an issue where using audio input with a sample rate requiring resampling
could result in empty audio being passed to STT services, causing errors.
- Fixed the VAD analyzer to process the full audio buffer as long as it contains
more than the minimum required bytes per iteration, instead of only analyzing
the first chunk.
- Fixed an issue in ParallelPipeline that caused errors when attempting to drain
the queues.
- Fixed an issue with emulated VAD timeout inconsistency in
`LLMUserContextAggregator`. Previously, emulated VAD scenarios (where
transcription is received without VAD detection) used a hardcoded
`aggregation_timeout` (default 0.5s) instead of matching the VAD's
`stop_secs` parameter (default 0.8s). This created different user experiences
between real VAD and emulated VAD scenarios. Now, emulated VAD timeouts
automatically synchronize with the VAD's `stop_secs` parameter.
- Fix a pipeline freeze when using AWS Nova Sonic, which would occur if the
user started early, while the bot was still working through
`trigger_assistant_response()`.
## [0.0.75] - 2025-07-08
### Added
- Added an `aggregate_sentences` arg in `CartesiaTTSService`,
`ElevenLabsTTSService`, `NeuphonicTTSService` and `RimeTTSService`, where the
default value is True. When `aggregate_sentences` is True, the `TTSService`
aggregates the LLM streamed tokens into sentences by default. Note: setting
the value to False requires a custom processor before the `TTSService` to
aggregate LLM tokens.
- Added `kwargs` to the `OLLamaLLMService` to allow for configuration args to
be passed to Ollama.
- Added call hang-up error handling in `TwilioFrameSerializer`, which handles
the case where the user has hung up before the `TwilioFrameSerializer` hangs
up the call.
### Changed
- Updated `RTVIObserver` and `RTVIProcessor` to match the new RTVI 1.0.0 protocol.
This includes:
- Deprecating support for all messages related to service configuaration and
actions.
- Adding support for obtaining and logging data about client, including its
RTVI version and optionally included system information (OS/browser/etc.)
- Adding support for handling the new `client-message` RTVI message through
either a `on_client_message` event handler or listening for a new
`RTVIClientMessageFrame`
- Adding support for responding to a `client-message` with a `server-response`
via either a direct call on the `RTVIProcessor` or via pushing a new
`RTVIServerResponseFrame`
- Adding built-in support for handling the new `append-to-context` RTVI message
which allows a client to add to the user or assistant llm context. No extra
code is required for supporting this behavior.
- Updating all JavaScript and React client RTVI examples to use versions 1.0.0
of the clients.
Get started migrating to RTVI protocol 1.0.0 by following the migration guide:
https://docs.pipecat.ai/client/migration-guide
- Refactored `AWSBedrockLLMService` and `AWSPollyTTSService` to work
asynchronously using `aioboto3` instead of the `boto3` library.
- The `UserIdleProcessor` now handles the scenario where function calls take
longer than the idle timeout duration. This allows you to use the
`UserIdleProcessor` in conjunction with function calls that take a while to
return a result.
### Fixed
- Updated the `NeuphonicTTSService` to work with the updated websocket API.
- Fixed an issue with `RivaSTTService` where the watchdog feature was causing
an error on initialization.
### Performance ### Performance
- Remove unncessary push task in each `FrameProcessor`. - Remove unncessary push task in each `FrameProcessor`.

View File

@@ -53,7 +53,7 @@ You can connect to Pipecat from any platform using our official SDKs:
| Category | Services | | Category | Services |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [Parakeet (NVIDIA)](https://docs.pipecat.ai/server/services/stt/parakeet), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | | Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [Parakeet (NVIDIA)](https://docs.pipecat.ai/server/services/stt/parakeet), [SambaNova (Whisper)](https://docs.pipecat.ai/server/services/stt/sambanova), [Soniox](https://docs.pipecat.ai/server/services/stt/soniox), [Speechmatics](https://docs.pipecat.ai/server/services/stt/speechmatics), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) | | LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/server/services/llm/sambanova) [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
| Text-to-Speech | [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [FastPitch (NVIDIA)](https://docs.pipecat.ai/server/services/tts/fastpitch), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | | Text-to-Speech | [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [FastPitch (NVIDIA)](https://docs.pipecat.ai/server/services/tts/fastpitch), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) |
| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) | | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |

View File

@@ -77,6 +77,7 @@ autodoc_mock_imports = [
"openpipe", "openpipe",
"simli", "simli",
"soundfile", "soundfile",
"soniox",
"pipecat_ai_krisp", "pipecat_ai_krisp",
"pyaudio", "pyaudio",
"_tkinter", "_tkinter",

View File

@@ -46,6 +46,7 @@ pipecat-ai[sambanova]
pipecat-ai[silero] pipecat-ai[silero]
pipecat-ai[simli] pipecat-ai[simli]
pipecat-ai[soundfile] pipecat-ai[soundfile]
pipecat-ai[soniox]
pipecat-ai[speechmatics] pipecat-ai[speechmatics]
pipecat-ai[tavus] pipecat-ai[tavus]
pipecat-ai[together] pipecat-ai[together]

View File

@@ -109,6 +109,9 @@ MINIMAX_GROUP_ID=...
# Sarvam AI # Sarvam AI
SARVAM_API_KEY=... SARVAM_API_KEY=...
# Soniox
SONIOX_API_KEY=
# Speechmatics # Speechmatics
SPEECHMATICS_API_KEY=... SPEECHMATICS_API_KEY=...

View File

@@ -0,0 +1,60 @@
# AWS Strands Examples
This folder contains two Python examples demonstrating how to use Pipecat with the AWS Strands agent.
## Overview
These examples show how to delegate complex, multi-step tasks to a Strands agent, which can reason step-by-step and call tools to accomplish user requests.
These examples are intentionally simplified for demonstration, using mock API calls. They work best if you ask it:
> What's the weather where the Golden Gate Bridge is?
## Example Scripts
### `black-box.py`
A minimal example that demonstrates how to use the Strands agent with Pipecat. The agent can handle multi-step queries by calling tools, but does not explain its reasoning out loud.
### `explain-thinking.py`
An enhanced example where the Strands agent explains each step of its reasoning in clear, simple language as it works through a multi-step task.
## Quick Start
1. **Clone the repository and navigate to this example:**
```bash
git clone https://github.com/pipecat-ai/pipecat.git
cd pipecat/examples/aws-strands
```
2. **Set up a virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Configure environment variables:**
Copy the provided `env.example` file to `.env` and fill in the necessary credentials:
```bash
cp env.example .env
# Then edit .env with your preferred editor
```
5. **Run an example:**
```bash
python black-box.py
# or
python explain-thinking.py
```

View File

@@ -0,0 +1,206 @@
#
# Copyright (c) 2025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import asyncio
import os
from dotenv import load_dotenv
from loguru import logger
from strands import Agent, tool
from strands.models import BedrockModel
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
"""This example demonstrates how to use the Strands agent with Pipecat.
You can delegate complex, multi-step tasks to the Strands agent, which can cycle through LLM-based reasoning and tool calls to accomplish the task.
Try asking: "What's the weather where the Golden Gate Bridge is?"
"""
# Strands agent tools
@tool
def get_location_name_from_landmark(landmark: str) -> str:
"""
Get the location name from a landmark.
Args:
landmark (str): The name of the landmark, e.g. "Golden Gate Bridge".
"""
# Simulate fetching location
return "San Francisco, CA"
@tool
def get_lat_long_from_location_name(location: str) -> dict:
"""
Get the latitude and longitude for a location name.
Args:
location (str): The city and state, e.g. "San Francisco, CA".
"""
# Simulate fetching lat/long from a geocoding service
return {"lat": 37.7749, "long": -122.4194}
@tool
def get_current_weather_from_lat_long(lat: float, long: float) -> dict:
"""
Get the current weather for a specific latitude and longitude.
Args:
lat (float): The latitude of the location.
long (float): The longitude of the location.
"""
# Simulate fetching weather data from a weather service
return {"conditions": "nice", "temperature": "75"}
# 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(),
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
strands_agent = Agent(
model=BedrockModel(
model_id="us.anthropic.claude-3-7-sonnet-20250219-v1:0", max_tokens=64000
),
tools=[
get_location_name_from_landmark,
get_lat_long_from_location_name,
get_current_weather_from_lat_long,
],
system_prompt="""
You are a helpful personal assistant who can look up information about places and weather.
Your key capabilities:
1. Look up where landmarks are located.
2. Find latitude and longitude for a location.
3. Look up the current weather for a specific latitude and longitude.
Explain each step of your reasoning in clear, simple, and concise language. Your responses will be converted to audio, so avoid special characters and numbered lists.
""",
)
async def handle_location_or_weather_related_queries(params: FunctionCallParams, query: str):
"""
Handle location or weather related queries.
Args:
query (str): The user's query, e.g. "What's the weather where the Golden Gate Bridge is?".
"""
# Run in a background thread
# (Otherwise the agent blocks the event loop; one effect of that is that we don't hear
# "let me check on that" until the agent finishes)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, strands_agent, query)
await params.result_callback(result.message)
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
llm.register_direct_function(handle_location_or_weather_related_queries)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
tools = ToolsSchema(standard_tools=[handle_location_or_weather_related_queries])
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way. Start by suggesting that the user ask about the weather where the Golden Gate Bridge is.",
},
]
context = OpenAILLMContext(messages, tools)
context_aggregator = llm.create_context_aggregator(context)
pipeline = Pipeline(
[
transport.input(),
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
@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=handle_sigint)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -0,0 +1,8 @@
OPENAI_API_KEY=
CARTESIA_API_KEY=
DEEPGRAM_API_KEY=
DAILY_API_KEY=
DAILY_SAMPLE_ROOM_URL=
AWS_SECRET_ACCESS_KEY=
AWS_ACCESS_KEY_ID=
AWS_REGION=

View File

@@ -0,0 +1,249 @@
#
# Copyright (c) 2025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import asyncio
import os
import threading
import time
from dotenv import load_dotenv
from loguru import logger
from strands import Agent, tool
from strands.models import BedrockModel
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
"""This example demonstrates how to use the Strands agent with Pipecat in a way where the agent explains its reasoning step-by-step.
You can delegate complex, multi-step tasks to the Strands agent, which can cycle through LLM-based reasoning and tool calls to accomplish the task.
Try asking: "What's the weather where the Golden Gate Bridge is?"
"""
# Strands agent tools
@tool
def get_location_name_from_landmark(landmark: str) -> str:
"""
Get the location name from a landmark.
Args:
landmark (str): The name of the landmark, e.g. "Golden Gate Bridge".
"""
# Simulate fetching location (slowly)
time.sleep(3)
return "San Francisco, CA"
@tool
def get_lat_long_from_location_name(location: str) -> dict:
"""
Get the latitude and longitude for a location name.
Args:
location (str): The city and state, e.g. "San Francisco, CA".
"""
# Simulate fetching lat/long from a geocoding service (slowly)
time.sleep(3)
return {"lat": 37.7749, "long": -122.4194}
@tool
def get_current_weather_from_lat_long(lat: float, long: float) -> dict:
"""
Get the current weather for a specific latitude and longitude.
Args:
lat (float): The latitude of the location.
long (float): The longitude of the location.
"""
# Simulate fetching weather data from a weather service (slowly)
time.sleep(3)
return {"conditions": "nice", "temperature": "75"}
# 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(),
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
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
)
next_strands_message_is_last = False
strands_messages_queue = asyncio.Queue()
def strands_callback_handler(**kwargs):
"""
Handle events from the Strands agent.
"""
nonlocal next_strands_message_is_last
if "event" in kwargs:
event_obj = kwargs["event"]
if event_obj and "messageStop" in event_obj:
message_stop = event_obj["messageStop"]
if message_stop and "stopReason" in message_stop:
stop_reason = message_stop["stopReason"]
if stop_reason == "end_turn":
next_strands_message_is_last = True
elif "message" in kwargs:
message_obj = kwargs["message"]
if message_obj and "content" in message_obj and "role" in message_obj:
role = message_obj["role"]
content = message_obj["content"]
if role == "assistant" and isinstance(content, list):
for content_obj in content:
if isinstance(content_obj, dict) and "text" in content_obj:
message = content_obj["text"]
if not next_strands_message_is_last:
strands_messages_queue.put_nowait(message)
async def process_strands_messages():
while True:
message = await strands_messages_queue.get()
await tts.queue_frame(TTSSpeakFrame(message))
strands_messages_queue.task_done()
asyncio.create_task(process_strands_messages())
strands_agent = Agent(
model=BedrockModel(
model_id="us.anthropic.claude-3-7-sonnet-20250219-v1:0", max_tokens=64000
),
tools=[
get_location_name_from_landmark,
get_lat_long_from_location_name,
get_current_weather_from_lat_long,
],
system_prompt="""
You are a helpful personal assistant who can look up information about places and weather.
Your key capabilities:
1. Look up where landmarks are located.
2. Find latitude and longitude for a location.
3. Look up the current weather for a specific latitude and longitude.
Explain each step of your reasoning in clear, simple, and concise language. Your responses will be converted to audio, so avoid special characters and numbered lists.
""",
callback_handler=strands_callback_handler,
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
async def handle_location_or_weather_related_queries(params: FunctionCallParams, query: str):
"""
Handle location or weather related queries.
Args:
query (str): The user's query, e.g. "What's the weather where the Golden Gate Bridge is?".
"""
# Run in a background thread
# (Otherwise the agent blocks the event loop; one effect of that is that we don't hear
# the agent's "thinking" messages until the agent finishes)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, strands_agent, query)
await params.result_callback(result.message)
llm.register_direct_function(handle_location_or_weather_related_queries)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
tools = ToolsSchema(standard_tools=[handle_location_or_weather_related_queries])
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way. Start by suggesting that the user ask about the weather where the Golden Gate Bridge is.",
},
]
context = OpenAILLMContext(messages, tools)
context_aggregator = llm.create_context_aggregator(context)
pipeline = Pipeline(
[
transport.input(),
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
@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=handle_sigint)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -0,0 +1,6 @@
fastapi
uvicorn
python-dotenv
pipecat-ai[webrtc,daily,deepgram,cartesia]
pipecat-ai-small-webrtc-prebuilt
strands-agents

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.10" "@pipecat-ai/daily-transport": "^1.0.0"
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
/** /**
* RTVI Client Implementation * Pipecat Client Implementation
* *
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily). * This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
* It handles audio/video streaming and manages the connection lifecycle. * It handles audio/video streaming and manages the connection lifecycle.
@@ -16,7 +16,7 @@
* - Browser with WebRTC support * - Browser with WebRTC support
*/ */
import { RTVIClient, RTVIEvent } from '@pipecat-ai/client-js'; import { PipecatClient, RTVIEvent } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport'; import { DailyTransport } from '@pipecat-ai/daily-transport';
/** /**
@@ -26,7 +26,7 @@ import { DailyTransport } from '@pipecat-ai/daily-transport';
class ChatbotClient { class ChatbotClient {
constructor() { constructor() {
// Initialize client state // Initialize client state
this.rtviClient = null; this.pcClient = null;
this.setupDOMElements(); this.setupDOMElements();
this.initializeClientAndTransport(); this.initializeClientAndTransport();
this.setupEventListeners(); this.setupEventListeners();
@@ -59,7 +59,7 @@ class ChatbotClient {
this.disconnectBtn.addEventListener('click', () => this.disconnect()); this.disconnectBtn.addEventListener('click', () => this.disconnect());
// Populate device selector // Populate device selector
this.rtviClient.getAllMics().then((mics) => { this.pcClient.getAllMics().then((mics) => {
console.log('Available mics:', mics); console.log('Available mics:', mics);
mics.forEach((device) => { mics.forEach((device) => {
const option = document.createElement('option'); const option = document.createElement('option');
@@ -71,16 +71,16 @@ class ChatbotClient {
this.deviceSelector.addEventListener('change', (event) => { this.deviceSelector.addEventListener('change', (event) => {
const selectedDeviceId = event.target.value; const selectedDeviceId = event.target.value;
console.log('Selected device ID:', selectedDeviceId); console.log('Selected device ID:', selectedDeviceId);
this.rtviClient.updateMic(selectedDeviceId); this.pcClient.updateMic(selectedDeviceId);
}); });
// Handle mic mute/unmute toggle // Handle mic mute/unmute toggle
const micToggleBtn = document.getElementById('mic-toggle-btn'); const micToggleBtn = document.getElementById('mic-toggle-btn');
micToggleBtn.addEventListener('click', () => { micToggleBtn.addEventListener('click', () => {
let micEnabled = this.rtviClient.isMicEnabled; let micEnabled = this.pcClient.isMicEnabled;
micToggleBtn.textContent = micEnabled ? 'Unmute Mic' : 'Mute Mic'; micToggleBtn.textContent = micEnabled ? 'Unmute Mic' : 'Mute Mic';
this.rtviClient.enableMic(!micEnabled); this.pcClient.enableMic(!micEnabled);
// Add logic to mute/unmute the mic // Add logic to mute/unmute the mic
if (micEnabled) { if (micEnabled) {
console.log('Mic muted'); console.log('Mic muted');
@@ -93,23 +93,12 @@ class ChatbotClient {
} }
/** /**
* Set up the RTVI client and Daily transport * Set up the Pipecat client and Daily transport
*/ */
async initializeClientAndTransport() { async initializeClientAndTransport() {
// Initialize the RTVI client with a DailyTransport and our configuration // Initialize the Pipecat client with a DailyTransport and our configuration
this.rtviClient = new RTVIClient({ this.pcClient = new PipecatClient({
transport: new DailyTransport(), transport: new DailyTransport(),
params: {
// REPLACE WITH YOUR MODAL URL ENDPOINT
baseUrl:
'https://<Modal workspace>--pipecat-modal-bot-launcher.modal.run',
endpoints: {
connect: '/connect',
},
requestData: {
bot_name: 'openai',
},
},
enableMic: true, // Enable microphone for user input enableMic: true, // Enable microphone for user input
enableCam: false, enableCam: false,
callbacks: { callbacks: {
@@ -176,8 +165,8 @@ class ChatbotClient {
// Set up listeners for media track events // Set up listeners for media track events
this.setupTrackListeners(); this.setupTrackListeners();
await this.rtviClient.initDevices(); await this.pcClient.initDevices();
window.client = this.rtviClient; window.client = this.pcClient;
} }
/** /**
@@ -212,10 +201,10 @@ class ChatbotClient {
* This is called when the bot is ready or when the transport state changes to ready * This is called when the bot is ready or when the transport state changes to ready
*/ */
setupMediaTracks() { setupMediaTracks() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Get current tracks from the client // Get current tracks from the client
const tracks = this.rtviClient.tracks(); const tracks = this.pcClient.tracks();
// Set up any available bot tracks // Set up any available bot tracks
if (tracks.bot?.audio) { if (tracks.bot?.audio) {
@@ -231,10 +220,10 @@ class ChatbotClient {
* This handles new tracks being added during the session * This handles new tracks being added during the session
*/ */
setupTrackListeners() { setupTrackListeners() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks // Only handle non-local (bot) tracks
if (!participant?.local) { if (!participant?.local) {
if (track.kind === 'audio') { if (track.kind === 'audio') {
@@ -253,7 +242,7 @@ class ChatbotClient {
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStopped, (track, participant) => {
if (participant.local) { if (participant.local) {
this.log('Local mic muted'); this.log('Local mic muted');
return; return;
@@ -311,21 +300,27 @@ class ChatbotClient {
/** /**
* Initialize and connect to the bot * Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection * This sets up the Pipecat client, initializes devices, and establishes the connection
*/ */
async connect() { async connect() {
try { try {
const botSelector = document.getElementById('bot-selector'); const botSelector = document.getElementById('bot-selector');
const selectedBot = botSelector.value; const selectedBot = botSelector.value;
this.rtviClient.params.requestData.bot_name = selectedBot;
// Initialize audio/video devices // Initialize audio/video devices
this.log('Initializing devices...'); this.log('Initializing devices...');
await this.rtviClient.initDevices(); await this.pcClient.initDevices();
// Connect to the bot // Connect to the bot
this.log(`Connecting to bot: ${selectedBot}`); this.log(`Connecting to bot: ${selectedBot}`);
await this.rtviClient.connect(); await this.pcClient.connect({
// REPLACE WITH YOUR MODAL URL ENDPOINT
endpoint:
'https://<your-workspace>--pipecat-modal-fastapi-app.modal.run/connect',
requestData: {
bot_name: selectedBot,
},
});
this.log('Connection complete'); this.log('Connection complete');
} catch (error) { } catch (error) {
@@ -336,9 +331,9 @@ class ChatbotClient {
this.updateStatus('Error'); this.updateStatus('Error');
// Clean up if there's an error // Clean up if there's an error
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
} catch (disconnectError) { } catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError.message}`); this.log(`Error during disconnect: ${disconnectError.message}`);
} }
@@ -350,10 +345,10 @@ class ChatbotClient {
* Disconnect from the bot and clean up media resources * Disconnect from the bot and clean up media resources
*/ */
async disconnect() { async disconnect() {
if (this.rtviClient) { if (this.pcClient) {
try { try {
// Disconnect the RTVI client // Disconnect the Pipecat client
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
// Clean up audio // Clean up audio
if (this.botAudio.srcObject) { if (this.botAudio.srcObject) {

View File

@@ -301,7 +301,7 @@ def fastapi_app():
allow_headers=["*"], allow_headers=["*"],
) )
# Include the endpoints from endpoints.py # Include the endpoints from this file
web_app.include_router(router) web_app.include_router(router)
return web_app return web_app

View File

@@ -1,2 +1,3 @@
python-dotenv==1.0.1 python-dotenv==1.0.1
modal==0.71.3 modal==1.0.5
fastapi[all]

View File

@@ -103,7 +103,7 @@ export default async function handler(req, res) {
const sip_config = { const sip_config = {
display_name: From, display_name: From,
sip_mode: 'dial-in', sip_mode: 'dial-in',
num_endpoints: call_transfer !== null ? 2 : 1, num_endpoints: (call_transfer !== undefined && call_transfer !== null) ? 2 : 1,
codecs: {"audio": ["OPUS"]}, codecs: {"audio": ["OPUS"]},
}; };
daily_room_properties.sip = sip_config; daily_room_properties.sip = sip_config;

View File

@@ -44,7 +44,7 @@ Try the hosted version of the demo here: https://pcc-smart-turn.vercel.app/.
4. Run the server: 4. Run the server:
```bash ```bash
LOCAL=1 python server.py LOCAL_RUN=1 python server.py
``` ```
### Run the client ### Run the client

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/client-react": "^0.3.5", "@pipecat-ai/client-react": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.10", "@pipecat-ai/daily-transport": "^1.0.0",
"next": "15.3.1", "next": "15.3.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View File

@@ -1,5 +1,5 @@
import './globals.css'; import './globals.css';
import { RTVIProvider } from '@/providers/RTVIProvider'; import { PipecatProvider } from '@/providers/PipecatProvider';
export const metadata = { export const metadata = {
title: 'Pipecat React Client', title: 'Pipecat React Client',
@@ -20,7 +20,7 @@ export default function RootLayout({
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
</head> </head>
<body> <body>
<RTVIProvider>{children}</RTVIProvider> <PipecatProvider>{children}</PipecatProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,22 +1,22 @@
'use client'; 'use client';
import { import {
RTVIClientAudio, PipecatClientAudio,
RTVIClientVideo, PipecatClientVideo,
useRTVIClientTransportState, usePipecatClientTransportState,
} from '@pipecat-ai/client-react'; } from '@pipecat-ai/client-react';
import { ConnectButton } from '../components/ConnectButton'; import { ConnectButton } from '../components/ConnectButton';
import { StatusDisplay } from '../components/StatusDisplay'; import { StatusDisplay } from '../components/StatusDisplay';
import { DebugDisplay } from '../components/DebugDisplay'; import { DebugDisplay } from '../components/DebugDisplay';
function BotVideo() { function BotVideo() {
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
const isConnected = transportState !== 'disconnected'; const isConnected = transportState !== 'disconnected';
return ( return (
<div className="bot-container"> <div className="bot-container">
<div className="video-container"> <div className="video-container">
{isConnected && <RTVIClientVideo participant="bot" fit="cover" />} {isConnected && <PipecatClientVideo participant="bot" fit="cover" />}
</div> </div>
</div> </div>
); );
@@ -35,7 +35,7 @@ export default function Home() {
</div> </div>
<DebugDisplay /> <DebugDisplay />
<RTVIClientAudio /> <PipecatClientAudio />
</div> </div>
); );
} }

View File

@@ -1,11 +1,17 @@
import { import {
useRTVIClient, usePipecatClient,
useRTVIClientTransportState, usePipecatClientTransportState,
} from '@pipecat-ai/client-react'; } from '@pipecat-ai/client-react';
// Get the API base URL from environment variables
// Default to "/api" if not specified
// "/api" is the default for Next.js API routes and used
// for the Pipecat Cloud deployed agent
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api';
export function ConnectButton() { export function ConnectButton() {
const client = useRTVIClient(); const client = usePipecatClient();
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
const isConnected = ['connected', 'ready'].includes(transportState); const isConnected = ['connected', 'ready'].includes(transportState);
const handleClick = async () => { const handleClick = async () => {
@@ -18,7 +24,10 @@ export function ConnectButton() {
if (isConnected) { if (isConnected) {
await client.disconnect(); await client.disconnect();
} else { } else {
await client.connect(); await client.connect({
endpoint: `${API_BASE_URL}/connect`,
requestData: { foo: 'bar' },
});
} }
} catch (error) { } catch (error) {
console.error('Connection error:', error); console.error('Connection error:', error);

View File

@@ -6,7 +6,7 @@ import {
TranscriptData, TranscriptData,
BotLLMTextData, BotLLMTextData,
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { useRTVIClient, useRTVIClientEvent } from '@pipecat-ai/client-react'; import { usePipecatClient, useRTVIClientEvent } from '@pipecat-ai/client-react';
import './DebugDisplay.css'; import './DebugDisplay.css';
interface SmartTurnResultData { interface SmartTurnResultData {
@@ -20,7 +20,7 @@ interface SmartTurnResultData {
export function DebugDisplay() { export function DebugDisplay() {
const debugLogRef = useRef<HTMLDivElement>(null); const debugLogRef = useRef<HTMLDivElement>(null);
const client = useRTVIClient(); const client = usePipecatClient();
const log = useCallback((message: string) => { const log = useCallback((message: string) => {
if (!debugLogRef.current) return; if (!debugLogRef.current) return;

View File

@@ -1,7 +1,7 @@
import { useRTVIClientTransportState } from '@pipecat-ai/client-react'; import { usePipecatClientTransportState } from '@pipecat-ai/client-react';
export function StatusDisplay() { export function StatusDisplay() {
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
return ( return (
<div className="status"> <div className="status">

View File

@@ -0,0 +1,28 @@
'use client';
import { PipecatClient } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport';
import { PipecatClientProvider } from '@pipecat-ai/client-react';
import { PropsWithChildren, useEffect, useState } from 'react';
export function PipecatProvider({ children }: PropsWithChildren) {
const [client, setClient] = useState<PipecatClient | null>(null);
useEffect(() => {
const pcClient = new PipecatClient({
transport: new DailyTransport(),
enableMic: true,
enableCam: false,
});
setClient(pcClient);
}, []);
if (!client) {
return null;
}
return (
<PipecatClientProvider client={client}>{children}</PipecatClientProvider>
);
}

View File

@@ -1,43 +0,0 @@
'use client';
import { RTVIClient } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport';
import { RTVIClientProvider } from '@pipecat-ai/client-react';
import { PropsWithChildren, useEffect, useState } from 'react';
// Get the API base URL from environment variables
// Default to "/api" if not specified
// "/api" is the default for Next.js API routes and used
// for the Pipecat Cloud deployed agent
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api';
console.log('Using API base URL:', API_BASE_URL);
export function RTVIProvider({ children }: PropsWithChildren) {
const [client, setClient] = useState<RTVIClient | null>(null);
useEffect(() => {
const transport = new DailyTransport();
const rtviClient = new RTVIClient({
transport,
params: {
baseUrl: API_BASE_URL,
endpoints: {
connect: '/connect',
},
requestData: { foo: 'bar' },
},
enableMic: true,
enableCam: false,
});
setClient(rtviClient);
}, []);
if (!client) {
return null;
}
return <RTVIClientProvider client={client}>{children}</RTVIClientProvider>;
}

View File

@@ -45,7 +45,7 @@ from pipecat.transports.services.daily import DailyParams, DailyTransport
load_dotenv(override=True) load_dotenv(override=True)
# Check if we're in local development mode # Check if we're in local development mode
LOCAL = os.getenv("LOCAL") LOCAL = os.getenv("LOCAL_RUN")
logger.remove() logger.remove()
logger.add(sys.stderr, level="DEBUG") logger.add(sys.stderr, level="DEBUG")

View File

@@ -0,0 +1,109 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.soniox.stt import SonioxSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
stt = SonioxSTTService(
api_key=os.getenv("SONIOX_API_KEY"),
)
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way.",
},
]
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
pipeline = Pipeline(
[
transport.input(), # Transport user input
stt,
context_aggregator.user(), # User responses
llm, # LLM
tts, # TTS
transport.output(), # Transport bot output
context_aggregator.assistant(), # Assistant spoken responses
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected")
# Kick off the conversation.
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
await task.queue_frames([context_aggregator.user().get_context_frame()])
@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=handle_sigint)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -0,0 +1,81 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import Frame, TranscriptionFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.soniox.stt import SonioxSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
class TranscriptionLogger(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TranscriptionFrame):
print(f"Transcription: {frame.text}")
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
stt = SonioxSTTService(
api_key=os.getenv("SONIOX_API_KEY"),
)
tl = TranscriptionLogger()
pipeline = Pipeline([transport.input(), stt, tl])
task = PipelineTask(pipeline)
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
@transport.event_handler("on_client_closed")
async def on_client_closed(transport, client):
logger.info(f"Client closed connection")
await task.cancel()
runner = PipelineRunner(handle_sigint=False)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -11,7 +11,7 @@ from dotenv import load_dotenv
from loguru import logger from loguru import logger
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
from pipecat.audio.turn.smart_turn.local_smart_turn import LocalSmartTurnAnalyzer from pipecat.audio.turn.smart_turn.local_smart_turn_v2 import LocalSmartTurnAnalyzerV2
from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
@@ -37,7 +37,7 @@ load_dotenv(override=True)
# # Hugging Face uses LFS to store large model files, including .mlpackage # # Hugging Face uses LFS to store large model files, including .mlpackage
# git lfs install # git lfs install
# # Clone the repo with the smart_turn_classifier.mlpackage # # Clone the repo with the smart_turn_classifier.mlpackage
# git clone https://huggingface.co/pipecat-ai/smart-turn # git clone https://huggingface.co/pipecat-ai/smart-turn-v2
# #
# Then set the env variable: # Then set the env variable:
# export LOCAL_SMART_TURN_MODEL_PATH=./smart-turn # export LOCAL_SMART_TURN_MODEL_PATH=./smart-turn
@@ -52,7 +52,7 @@ transport_params = {
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
turn_analyzer=LocalSmartTurnAnalyzer( turn_analyzer=LocalSmartTurnAnalyzerV2(
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams() smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
), ),
), ),
@@ -60,7 +60,7 @@ transport_params = {
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
turn_analyzer=LocalSmartTurnAnalyzer( turn_analyzer=LocalSmartTurnAnalyzerV2(
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams() smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
), ),
), ),
@@ -68,7 +68,7 @@ transport_params = {
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=True, audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
turn_analyzer=LocalSmartTurnAnalyzer( turn_analyzer=LocalSmartTurnAnalyzerV2(
smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams() smart_turn_model_path=smart_turn_model_path, params=SmartTurnParams()
), ),
), ),

View File

@@ -20,11 +20,10 @@ import {
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { import {
ProtobufFrameSerializer, ProtobufFrameSerializer,
WebSocketTransport WebSocketTransport,
} from "@pipecat-ai/websocket-transport"; } from '@pipecat-ai/websocket-transport';
class RecordingSerializer extends ProtobufFrameSerializer { class RecordingSerializer extends ProtobufFrameSerializer {
private lastTimestamp: number | null = null; private lastTimestamp: number | null = null;
private recordingAudioToSend: boolean = false; private recordingAudioToSend: boolean = false;
private _recordedAudio: { data: ArrayBuffer; delay: number }[] = []; private _recordedAudio: { data: ArrayBuffer; delay: number }[] = [];
@@ -40,7 +39,11 @@ class RecordingSerializer extends ProtobufFrameSerializer {
} }
// @ts-ignore // @ts-ignore
serializeAudio(data: ArrayBuffer, sampleRate: number, numChannels: number): Uint8Array | null { serializeAudio(
data: ArrayBuffer,
sampleRate: number,
numChannels: number
): Uint8Array | null {
if (this.recordingAudioToSend) { if (this.recordingAudioToSend) {
const now = Date.now(); const now = Date.now();
// Compute delay since last packet // Compute delay since last packet
@@ -55,13 +58,13 @@ class RecordingSerializer extends ProtobufFrameSerializer {
} }
public get recordedAudio() { public get recordedAudio() {
return this._recordedAudio return this._recordedAudio;
} }
} }
class WebsocketClientApp { class WebsocketClientApp {
private ENABLE_RECORDING_MODE = false private ENABLE_RECORDING_MODE = false;
private RECORDING_TIME_MS = 10000 private RECORDING_TIME_MS = 10000;
private rtviClient: RTVIClient | null = null; private rtviClient: RTVIClient | null = null;
private connectBtn: HTMLButtonElement | null = null; private connectBtn: HTMLButtonElement | null = null;
@@ -71,7 +74,7 @@ class WebsocketClientApp {
private botAudio: HTMLAudioElement; private botAudio: HTMLAudioElement;
private declare websocketTransport: WebSocketTransport; private declare websocketTransport: WebSocketTransport;
private sendRecordedAudio: boolean = false private sendRecordedAudio: boolean = false;
private declare recordingSerializer: RecordingSerializer; private declare recordingSerializer: RecordingSerializer;
private playBtn: HTMLButtonElement | null = null; private playBtn: HTMLButtonElement | null = null;
@@ -91,8 +94,12 @@ class WebsocketClientApp {
* Set up references to DOM elements and create necessary media elements * Set up references to DOM elements and create necessary media elements
*/ */
private setupDOMElements(): void { private setupDOMElements(): void {
this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; this.connectBtn = document.getElementById(
this.disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 'connect-btn'
) as HTMLButtonElement;
this.disconnectBtn = document.getElementById(
'disconnect-btn'
) as HTMLButtonElement;
this.statusSpan = document.getElementById('connection-status'); this.statusSpan = document.getElementById('connection-status');
this.debugLog = document.getElementById('debug-log'); this.debugLog = document.getElementById('debug-log');
this.playBtn = document.getElementById('play-btn') as HTMLButtonElement; this.playBtn = document.getElementById('play-btn') as HTMLButtonElement;
@@ -105,8 +112,12 @@ class WebsocketClientApp {
private setupEventListeners(): void { private setupEventListeners(): void {
this.connectBtn?.addEventListener('click', () => this.connect()); this.connectBtn?.addEventListener('click', () => this.connect());
this.disconnectBtn?.addEventListener('click', () => this.disconnect()); this.disconnectBtn?.addEventListener('click', () => this.disconnect());
this.playBtn?.addEventListener('click', () => this.startSendingRecordedAudio()); this.playBtn?.addEventListener('click', () =>
this.stopBtn?.addEventListener('click', () => this.stopSendingRecordedAudio()); this.startSendingRecordedAudio()
);
this.stopBtn?.addEventListener('click', () =>
this.stopSendingRecordedAudio()
);
} }
/** /**
@@ -165,7 +176,9 @@ class WebsocketClientApp {
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log(`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`); this.log(
`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`
);
}); });
} }
@@ -175,7 +188,10 @@ class WebsocketClientApp {
*/ */
private setupAudioTrack(track: MediaStreamTrack): void { private setupAudioTrack(track: MediaStreamTrack): void {
this.log('Setting up audio track'); this.log('Setting up audio track');
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
const oldTrack = this.botAudio.srcObject.getAudioTracks()[0]; const oldTrack = this.botAudio.srcObject.getAudioTracks()[0];
if (oldTrack?.id === track.id) return; if (oldTrack?.id === track.id) return;
} }
@@ -190,27 +206,17 @@ class WebsocketClientApp {
try { try {
const startTime = Date.now(); const startTime = Date.now();
this.recordingSerializer = new RecordingSerializer() this.recordingSerializer = new RecordingSerializer();
const transport = this.ENABLE_RECORDING_MODE ? const ws_opts = {
new WebSocketTransport({ serializer: this.ENABLE_RECORDING_MODE
serializer: this.recordingSerializer, ? this.recordingSerializer
recorderSampleRate: 8000, : new ProtobufFrameSerializer(),
playerSampleRate:8000 recorderSampleRate: 8000,
}) : playerSampleRate: 8000,
new WebSocketTransport({ };
serializer: new ProtobufFrameSerializer(),
recorderSampleRate: 8000,
playerSampleRate:8000
});
this.websocketTransport = transport
const RTVIConfig: RTVIClientOptions = { const RTVIConfig: RTVIClientOptions = {
transport, transport: new WebSocketTransport(ws_opts),
params: {
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: { connect: '/connect' },
},
enableMic: true, enableMic: true,
enableCam: false, enableCam: false,
callbacks: { callbacks: {
@@ -238,27 +244,34 @@ class WebsocketClientApp {
onMessageError: (error) => console.error('Message error:', error), onMessageError: (error) => console.error('Message error:', error),
onError: (error) => console.error('Error:', error), onError: (error) => console.error('Error:', error),
}, },
} };
this.rtviClient = new RTVIClient(RTVIConfig); this.rtviClient = new RTVIClient(RTVIConfig);
this.websocketTransport = this.rtviClient.transport;
this.setupTrackListeners(); this.setupTrackListeners();
this.log('Initializing devices...'); this.log('Initializing devices...');
await this.rtviClient.initDevices(); await this.rtviClient.initDevices();
this.log('Connecting to bot...'); this.log('Connecting to bot...');
await this.rtviClient.connect(); await this.rtviClient.connect({
endpoint: 'http://localhost:7860/connect',
});
const timeTaken = Date.now() - startTime; const timeTaken = Date.now() - startTime;
this.log(`Connection complete, timeTaken: ${timeTaken}`); this.log(`Connection complete, timeTaken: ${timeTaken}`);
if (this.ENABLE_RECORDING_MODE) { if (this.ENABLE_RECORDING_MODE) {
this.log(`Starting to recording the next ${(this.RECORDING_TIME_MS/1000)}s of audio`); this.log(
this.recordingSerializer.startRecording() `Starting to recording the next ${
await this.sleep(this.RECORDING_TIME_MS) this.RECORDING_TIME_MS / 1000
this.recordingSerializer.stopRecording() }s of audio`
this.log("Recording stopped"); );
this.rtviClient.enableMic(false) this.recordingSerializer.startRecording();
this.startSendingRecordedAudio() await this.sleep(this.RECORDING_TIME_MS);
this.recordingSerializer.stopRecording();
this.log('Recording stopped');
this.rtviClient.enableMic(false);
this.startSendingRecordedAudio();
} }
} catch (error) { } catch (error) {
this.log(`Error connecting: ${(error as Error).message}`); this.log(`Error connecting: ${(error as Error).message}`);
@@ -280,11 +293,16 @@ class WebsocketClientApp {
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
if (this.rtviClient) { if (this.rtviClient) {
try { try {
this.stopSendingRecordedAudio() this.stopSendingRecordedAudio();
await this.rtviClient.disconnect(); await this.rtviClient.disconnect();
this.rtviClient = null; this.rtviClient = null;
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject.getAudioTracks().forEach((track) => track.stop()); this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
this.botAudio.srcObject
.getAudioTracks()
.forEach((track) => track.stop());
this.botAudio.srcObject = null; this.botAudio.srcObject = null;
} }
} catch (error) { } catch (error) {
@@ -294,21 +312,21 @@ class WebsocketClientApp {
} }
private startSendingRecordedAudio() { private startSendingRecordedAudio() {
this.sendRecordedAudio = true this.sendRecordedAudio = true;
if (this.playBtn) this.playBtn.disabled = true; if (this.playBtn) this.playBtn.disabled = true;
if (this.stopBtn) this.stopBtn.disabled = false; if (this.stopBtn) this.stopBtn.disabled = false;
void this.replayAudio() void this.replayAudio();
} }
private stopSendingRecordedAudio() { private stopSendingRecordedAudio() {
if (this.stopBtn) this.stopBtn.disabled = true; if (this.stopBtn) this.stopBtn.disabled = true;
if (this.playBtn) this.playBtn.disabled = false; if (this.playBtn) this.playBtn.disabled = false;
this.sendRecordedAudio = false this.sendRecordedAudio = false;
} }
private async replayAudio() { private async replayAudio() {
if (this.sendRecordedAudio) { if (this.sendRecordedAudio) {
this.log("Sending recorded audio") this.log('Sending recorded audio');
for (const chunk of this.recordingSerializer.recordedAudio) { for (const chunk of this.recordingSerializer.recordedAudio) {
await this.sleep(chunk.delay); await this.sleep(chunk.delay);
this.websocketTransport.handleUserAudioStream(chunk.data); this.websocketTransport.handleUserAudioStream(chunk.data);
@@ -316,14 +334,13 @@ class WebsocketClientApp {
const randomDelay = 1000 + Math.random() * (10000 - 500); const randomDelay = 1000 + Math.random() * (10000 - 500);
await this.sleep(randomDelay); await this.sleep(randomDelay);
void this.replayAudio() void this.replayAudio();
} }
} }
private sleep(ms: number): Promise<void> { private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
} }
declare global { declare global {

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.8" "@pipecat-ai/daily-transport": "^1.0.0"
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
/** /**
* RTVI Client Implementation * Pipecat Client Implementation
* *
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily). * This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
* It handles audio/video streaming and manages the connection lifecycle. * It handles audio/video streaming and manages the connection lifecycle.
@@ -18,20 +18,22 @@
import { import {
Participant, Participant,
RTVIClient, PipecatClient,
RTVIClientOptions, PipecatClientOptions,
RTVIEvent, RTVIEvent,
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport'; import {
DailyEventCallbacks,
DailyTransport,
} from '@pipecat-ai/daily-transport';
import SoundUtils from './util/soundUtils'; import SoundUtils from './util/soundUtils';
import { InstantVoiceHelper } from './util/instantVoiceHelper';
/** /**
* InstantVoiceClient handles the connection and media management for a real-time * InstantVoiceClient handles the connection and media management for a real-time
* voice and video interaction with an AI bot. * voice and video interaction with an AI bot.
*/ */
class InstantVoiceClient { class InstantVoiceClient {
private declare rtviClient: RTVIClient; private declare pcClient: PipecatClient;
private connectBtn: HTMLButtonElement | null = null; private connectBtn: HTMLButtonElement | null = null;
private disconnectBtn: HTMLButtonElement | null = null; private disconnectBtn: HTMLButtonElement | null = null;
private statusSpan: HTMLElement | null = null; private statusSpan: HTMLElement | null = null;
@@ -46,7 +48,7 @@ class InstantVoiceClient {
document.body.appendChild(this.botAudio); document.body.appendChild(this.botAudio);
this.setupDOMElements(); this.setupDOMElements();
this.setupEventListeners(); this.setupEventListeners();
this.initializeRTVIClient(); this.initializePipecatClient();
} }
/** /**
@@ -72,16 +74,11 @@ class InstantVoiceClient {
this.disconnectBtn?.addEventListener('click', () => this.disconnect()); this.disconnectBtn?.addEventListener('click', () => this.disconnect());
} }
private initializeRTVIClient(): void { private initializePipecatClient(): void {
const RTVIConfig: RTVIClientOptions = { const PipecatConfig: PipecatClientOptions = {
transport: new DailyTransport({ transport: new DailyTransport({
bufferLocalAudioUntilBotReady: true, bufferLocalAudioUntilBotReady: true,
}), }),
params: {
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: { connect: '/connect' },
},
enableMic: true, enableMic: true,
enableCam: false, enableCam: false,
callbacks: { callbacks: {
@@ -113,30 +110,23 @@ class InstantVoiceClient {
onBotTranscript: (data) => this.log(`Bot: ${data.text}`), onBotTranscript: (data) => this.log(`Bot: ${data.text}`),
onMessageError: (error) => console.error('Message error:', error), onMessageError: (error) => console.error('Message error:', error),
onError: (error) => console.error('Error:', error), onError: (error) => console.error('Error:', error),
}, onAudioBufferingStarted: () => {
SoundUtils.beep();
this.updateBufferingStatus('Yes');
this.log(
`onMicCaptureStarted, timeTaken: ${Date.now() - this.startTime}`
);
},
onAudioBufferingStopped: () => {
this.updateBufferingStatus('No');
this.log(
`onMicCaptureStopped, timeTaken: ${Date.now() - this.startTime}`
);
},
} as DailyEventCallbacks,
}; };
this.rtviClient = new RTVIClient(RTVIConfig); this.pcClient = new PipecatClient(PipecatConfig);
this.rtviClient.registerHelper(
'transport',
new InstantVoiceHelper({
callbacks: {
onAudioBufferingStarted: () => {
SoundUtils.beep();
this.updateBufferingStatus('Yes');
this.log(
`onMicCaptureStarted, timeTaken: ${Date.now() - this.startTime}`
);
},
onAudioBufferingStopped: () => {
this.updateBufferingStatus('No');
this.log(
`onMicCaptureStopped, timeTaken: ${Date.now() - this.startTime}`
);
},
},
})
);
this.setupTrackListeners(); this.setupTrackListeners();
} }
@@ -182,8 +172,8 @@ class InstantVoiceClient {
* This is called when the bot is ready or when the transport state changes to ready * This is called when the bot is ready or when the transport state changes to ready
*/ */
setupMediaTracks() { setupMediaTracks() {
if (!this.rtviClient) return; if (!this.pcClient) return;
const tracks = this.rtviClient.tracks(); const tracks = this.pcClient.tracks();
if (tracks.bot?.audio) { if (tracks.bot?.audio) {
this.setupAudioTrack(tracks.bot.audio); this.setupAudioTrack(tracks.bot.audio);
} }
@@ -194,10 +184,10 @@ class InstantVoiceClient {
* This handles new tracks being added during the session * This handles new tracks being added during the session
*/ */
setupTrackListeners() { setupTrackListeners() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks // Only handle non-local (bot) tracks
if (!participant?.local && track.kind === 'audio') { if (!participant?.local && track.kind === 'audio') {
this.setupAudioTrack(track); this.setupAudioTrack(track);
@@ -205,7 +195,7 @@ class InstantVoiceClient {
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log( this.log(
`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}` `Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`
); );
@@ -230,22 +220,25 @@ class InstantVoiceClient {
/** /**
* Initialize and connect to the bot * Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection * This sets up the Pipecat client, initializes devices, and establishes the connection
*/ */
public async connect(): Promise<void> { public async connect(): Promise<void> {
try { try {
this.startTime = Date.now(); this.startTime = Date.now();
this.log('Connecting to bot...'); this.log('Connecting to bot...');
await this.rtviClient.connect(); await this.pcClient.connect({
// The baseURL and endpoint of your bot server that the client will connect to
endpoint: 'http://localhost:7860/connect',
});
} catch (error) { } catch (error) {
this.log(`Error connecting: ${(error as Error).message}`); this.log(`Error connecting: ${(error as Error).message}`);
this.updateStatus('Error'); this.updateStatus('Error');
this.updateBufferingStatus('No'); this.updateBufferingStatus('No');
// Clean up if there's an error // Clean up if there's an error
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
} catch (disconnectError) { } catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError}`); this.log(`Error during disconnect: ${disconnectError}`);
} }
@@ -258,7 +251,7 @@ class InstantVoiceClient {
*/ */
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
if ( if (
this.botAudio.srcObject && this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject 'getAudioTracks' in this.botAudio.srcObject

View File

@@ -1,39 +0,0 @@
import {RTVIClientHelper, RTVIClientHelperOptions, RTVIMessage} from "@pipecat-ai/client-js";
import {DailyRTVIMessageType} from '@pipecat-ai/daily-transport';
export type InstantVoiceHelperCallbacks = Partial<{
onAudioBufferingStarted: () => void;
onAudioBufferingStopped: () => void;
}>;
// --- Interface and class
export interface InstantVoiceHelperOptions extends RTVIClientHelperOptions {
callbacks?: InstantVoiceHelperCallbacks;
}
export class InstantVoiceHelper extends RTVIClientHelper {
protected declare _options: InstantVoiceHelperOptions;
constructor(options: InstantVoiceHelperOptions) {
super(options);
}
handleMessage(rtviMessage: RTVIMessage): void {
switch (rtviMessage.type) {
case DailyRTVIMessageType.AUDIO_BUFFERING_STARTED:
if (this._options.callbacks?.onAudioBufferingStarted) {
this._options.callbacks?.onAudioBufferingStarted()
}
break;
case DailyRTVIMessageType.AUDIO_BUFFERING_STOPPED:
if (this._options.callbacks?.onAudioBufferingStopped) {
this._options.callbacks?.onAudioBufferingStopped()
}
break;
}
}
getMessageTypes(): string[] {
return [DailyRTVIMessageType.AUDIO_BUFFERING_STARTED, DailyRTVIMessageType.AUDIO_BUFFERING_STOPPED];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.8" "@pipecat-ai/daily-transport": "^1.0.0"
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
/** /**
* RTVI Client Implementation * Pipecat Client Implementation
* *
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily). * This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
* It handles audio/video streaming and manages the connection lifecycle. * It handles audio/video streaming and manages the connection lifecycle.
@@ -16,78 +16,9 @@
* - Browser with WebRTC support * - Browser with WebRTC support
*/ */
import { import { LogLevel, PipecatClient, RTVIEvent } from '@pipecat-ai/client-js';
LogLevel,
RTVIClient,
RTVIClientHelper,
RTVIEvent,
} from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport'; import { DailyTransport } from '@pipecat-ai/daily-transport';
class SearchResponseHelper extends RTVIClientHelper {
constructor(contentPanel) {
super();
this.contentPanel = contentPanel;
}
handleMessage(rtviMessage) {
console.log('SearchResponseHelper, received message:', rtviMessage);
if (rtviMessage.data) {
// Clear existing content
this.contentPanel.innerHTML = '';
// Create a container for all content
const contentContainer = document.createElement('div');
contentContainer.className = 'content-container';
// Add the search_result
if (rtviMessage.data.search_result) {
const searchResultDiv = document.createElement('div');
searchResultDiv.className = 'search-result';
searchResultDiv.textContent = rtviMessage.data.search_result;
contentContainer.appendChild(searchResultDiv);
}
// Add the sources
if (rtviMessage.data.origins) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'sources';
const sourcesTitle = document.createElement('h3');
sourcesTitle.className = 'sources-title';
sourcesTitle.textContent = 'Sources:';
sourcesDiv.appendChild(sourcesTitle);
rtviMessage.data.origins.forEach((origin) => {
const sourceLink = document.createElement('a');
sourceLink.className = 'source-link';
sourceLink.href = origin.site_uri;
sourceLink.target = '_blank';
sourceLink.textContent = origin.site_title;
sourcesDiv.appendChild(sourceLink);
});
contentContainer.appendChild(sourcesDiv);
}
// Add the rendered_content in an iframe
if (rtviMessage.data.rendered_content) {
const iframe = document.createElement('iframe');
iframe.className = 'iframe-container';
iframe.srcdoc = rtviMessage.data.rendered_content;
contentContainer.appendChild(iframe);
}
// Append the content container to the content panel
this.contentPanel.appendChild(contentContainer);
}
}
getMessageTypes() {
return ['bot-llm-search-response'];
}
}
/** /**
* ChatbotClient handles the connection and media management for a real-time * ChatbotClient handles the connection and media management for a real-time
* voice and video interaction with an AI bot. * voice and video interaction with an AI bot.
@@ -95,7 +26,7 @@ class SearchResponseHelper extends RTVIClientHelper {
class ChatbotClient { class ChatbotClient {
constructor() { constructor() {
// Initialize client state // Initialize client state
this.rtviClient = null; this.pcClient = null;
this.setupDOMElements(); this.setupDOMElements();
this.setupEventListeners(); this.setupEventListeners();
} }
@@ -160,10 +91,10 @@ class ChatbotClient {
* This is called when the bot is ready or when the transport state changes to ready * This is called when the bot is ready or when the transport state changes to ready
*/ */
setupMediaTracks() { setupMediaTracks() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Get current tracks from the client // Get current tracks from the client
const tracks = this.rtviClient.tracks(); const tracks = this.pcClient.tracks();
// Set up any available bot tracks // Set up any available bot tracks
if (tracks.bot?.audio) { if (tracks.bot?.audio) {
@@ -176,10 +107,10 @@ class ChatbotClient {
* This handles new tracks being added during the session * This handles new tracks being added during the session
*/ */
setupTrackListeners() { setupTrackListeners() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks // Only handle non-local (bot) tracks
if (!participant?.local && track.kind === 'audio') { if (!participant?.local && track.kind === 'audio') {
this.setupAudioTrack(track); this.setupAudioTrack(track);
@@ -187,7 +118,7 @@ class ChatbotClient {
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log( this.log(
`Track stopped event: ${track.kind} from ${ `Track stopped event: ${track.kind} from ${
participant?.name || 'unknown' participant?.name || 'unknown'
@@ -213,20 +144,13 @@ class ChatbotClient {
/** /**
* Initialize and connect to the bot * Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection * This sets up the Pipecat client, initializes devices, and establishes the connection
*/ */
async connect() { async connect() {
try { try {
// Initialize the RTVI client with a Daily WebRTC transport and our configuration // Initialize the Pipecat client with a Daily WebRTC transport and our configuration
this.rtviClient = new RTVIClient({ this.pcClient = new PipecatClient({
transport: new DailyTransport(), transport: new DailyTransport(),
params: {
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: {
connect: '/connect',
},
},
enableMic: true, // Enable microphone for user input enableMic: true, // Enable microphone for user input
enableCam: false, enableCam: false,
callbacks: { callbacks: {
@@ -251,6 +175,8 @@ class ChatbotClient {
this.setupMediaTracks(); this.setupMediaTracks();
} }
}, },
// Handle search response events
onBotLlmSearchResponse: this.handleSearchResponse.bind(this),
// Handle bot connection events // Handle bot connection events
onBotConnected: (participant) => { onBotConnected: (participant) => {
this.log(`Bot connected: ${JSON.stringify(participant)}`); this.log(`Bot connected: ${JSON.stringify(participant)}`);
@@ -281,22 +207,22 @@ class ChatbotClient {
}, },
}, },
}); });
//this.rtviClient.setLogLevel(LogLevel.DEBUG)
this.rtviClient.registerHelper( //this.pcClient.setLogLevel(LogLevel.DEBUG)
'llm',
new SearchResponseHelper(this.searchResultContainer)
);
// Set up listeners for media track events // Set up listeners for media track events
this.setupTrackListeners(); this.setupTrackListeners();
// Initialize audio devices // Initialize audio devices
this.log('Initializing devices...'); this.log('Initializing devices...');
await this.rtviClient.initDevices(); await this.pcClient.initDevices();
// Connect to the bot // Connect to the bot
this.log('Connecting to bot...'); this.log('Connecting to bot...');
await this.rtviClient.connect(); await this.pcClient.connect({
// The baseURL and endpoint of your bot server that the client will connect to
endpoint: 'http://localhost:7860/connect',
});
this.log('Connection complete'); this.log('Connection complete');
} catch (error) { } catch (error) {
@@ -306,9 +232,9 @@ class ChatbotClient {
this.updateStatus('Error'); this.updateStatus('Error');
// Clean up if there's an error // Clean up if there's an error
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
} catch (disconnectError) { } catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError.message}`); this.log(`Error during disconnect: ${disconnectError.message}`);
} }
@@ -320,11 +246,11 @@ class ChatbotClient {
* Disconnect from the bot and clean up media resources * Disconnect from the bot and clean up media resources
*/ */
async disconnect() { async disconnect() {
if (this.rtviClient) { if (this.pcClient) {
try { try {
// Disconnect the RTVI client // Disconnect the Pipecat client
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
this.rtviClient = null; this.pcClient = null;
// Clean up audio // Clean up audio
if (this.botAudio.srcObject) { if (this.botAudio.srcObject) {
@@ -339,6 +265,57 @@ class ChatbotClient {
} }
} }
} }
handleSearchResponse(response) {
console.log('SearchResponseHelper, received message:', response);
// Clear existing content
this.searchResultContainer.innerHTML = '';
// Create a container for all content
const contentContainer = document.createElement('div');
contentContainer.className = 'content-container';
// Add the search_result
if (response.search_result) {
const searchResultDiv = document.createElement('div');
searchResultDiv.className = 'search-result';
searchResultDiv.textContent = response.search_result;
contentContainer.appendChild(searchResultDiv);
}
// Add the sources
if (response.origins) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'sources';
const sourcesTitle = document.createElement('h3');
sourcesTitle.className = 'sources-title';
sourcesTitle.textContent = 'Sources:';
sourcesDiv.appendChild(sourcesTitle);
response.origins.forEach((origin) => {
const sourceLink = document.createElement('a');
sourceLink.className = 'source-link';
sourceLink.href = origin.site_uri;
sourceLink.target = '_blank';
sourceLink.textContent = origin.site_title;
sourcesDiv.appendChild(sourceLink);
});
contentContainer.appendChild(sourcesDiv);
}
// Add the rendered_content in an iframe
if (response.rendered_content) {
const iframe = document.createElement('iframe');
iframe.className = 'iframe-container';
iframe.srcdoc = response.rendered_content;
contentContainer.appendChild(iframe);
}
// Append the content container to the content panel
this.searchResultContainer.appendChild(contentContainer);
}
} }
// Initialize the client when the page loads // Initialize the client when the page loads

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.2", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/small-webrtc-transport": "^0.0.2" "@pipecat-ai/small-webrtc-transport": "^1.0.0"
} }
} }

View File

@@ -1,217 +1,236 @@
import { SmallWebRTCTransport } from '@pipecat-ai/small-webrtc-transport';
import { import {
SmallWebRTCTransport BotLLMTextData,
} from "@pipecat-ai/small-webrtc-transport"; Participant,
import {Participant, RTVIClient, RTVIClientOptions, Transport} from "@pipecat-ai/client-js"; PipecatClient,
PipecatClientOptions,
TranscriptData,
TransportState,
} from '@pipecat-ai/client-js';
class WebRTCApp { class WebRTCApp {
private declare connectBtn: HTMLButtonElement;
private declare disconnectBtn: HTMLButtonElement;
private declare muteBtn: HTMLButtonElement;
private declare connectBtn: HTMLButtonElement; private declare audioInput: HTMLSelectElement;
private declare disconnectBtn: HTMLButtonElement; private declare videoInput: HTMLSelectElement;
private declare muteBtn: HTMLButtonElement; private declare audioCodec: HTMLSelectElement;
private declare videoCodec: HTMLSelectElement;
private declare audioInput: HTMLSelectElement; private declare videoElement: HTMLVideoElement;
private declare videoInput: HTMLSelectElement; private declare audioElement: HTMLAudioElement;
private declare audioCodec: HTMLSelectElement;
private declare videoCodec: HTMLSelectElement;
private declare videoElement: HTMLVideoElement; private debugLog: HTMLElement | null = null;
private declare audioElement: HTMLAudioElement; private statusSpan: HTMLElement | null = null;
private debugLog: HTMLElement | null = null; private declare smallWebRTCTransport: SmallWebRTCTransport;
private statusSpan: HTMLElement | null = null; private declare pcClient: PipecatClient;
private declare smallWebRTCTransport: SmallWebRTCTransport; constructor() {
private declare rtviClient: RTVIClient; this.setupDOMElements();
this.setupDOMEventListeners();
this.initializePipecatClient();
void this.populateDevices();
}
constructor() { private initializePipecatClient(): void {
this.setupDOMElements(); const opts: PipecatClientOptions = {
this.setupDOMEventListeners(); transport: new SmallWebRTCTransport({ connectionUrl: '/api/offer' }),
this.initializeRTVIClient() enableMic: true,
void this.populateDevices(); enableCam: true,
callbacks: {
onTransportStateChanged: (state: TransportState) => {
this.log(`Transport state: ${state}`);
},
onConnected: () => {
this.onConnectedHandler();
},
onBotReady: () => {
this.log('Bot is ready.');
},
onDisconnected: () => {
this.onDisconnectedHandler();
},
onUserStartedSpeaking: () => {
this.log('User started speaking.');
},
onUserStoppedSpeaking: () => {
this.log('User stopped speaking.');
},
onBotStartedSpeaking: () => {
this.log('Bot started speaking.');
},
onBotStoppedSpeaking: () => {
this.log('Bot stopped speaking.');
},
onUserTranscript: (transcript: TranscriptData) => {
if (transcript.final) {
this.log(`User transcript: ${transcript.text}`);
}
},
onBotTranscript: (data: BotLLMTextData) => {
this.log(`Bot transcript: ${data.text}`);
},
onTrackStarted: (
track: MediaStreamTrack,
participant?: Participant
) => {
if (participant?.local) {
return;
}
this.onBotTrackStarted(track);
},
onServerMessage: (msg: unknown) => {
this.log(`Server message: ${msg}`);
},
},
};
this.pcClient = new PipecatClient(opts);
this.smallWebRTCTransport = this.pcClient.transport as SmallWebRTCTransport;
}
private setupDOMElements(): void {
this.connectBtn = document.getElementById(
'connect-btn'
) as HTMLButtonElement;
this.disconnectBtn = document.getElementById(
'disconnect-btn'
) as HTMLButtonElement;
this.muteBtn = document.getElementById('mute-btn') as HTMLButtonElement;
this.audioInput = document.getElementById(
'audio-input'
) as HTMLSelectElement;
this.videoInput = document.getElementById(
'video-input'
) as HTMLSelectElement;
this.audioCodec = document.getElementById(
'audio-codec'
) as HTMLSelectElement;
this.videoCodec = document.getElementById(
'video-codec'
) as HTMLSelectElement;
this.videoElement = document.getElementById(
'bot-video'
) as HTMLVideoElement;
this.audioElement = document.getElementById(
'bot-audio'
) as HTMLAudioElement;
this.debugLog = document.getElementById('debug-log');
this.statusSpan = document.getElementById('connection-status');
}
private setupDOMEventListeners(): void {
this.connectBtn.addEventListener('click', () => this.start());
this.disconnectBtn.addEventListener('click', () => this.stop());
this.audioInput.addEventListener('change', (e) => {
// @ts-ignore
let audioDevice = e.target?.value;
this.pcClient.updateMic(audioDevice);
});
this.videoInput.addEventListener('change', (e) => {
// @ts-ignore
let videoDevice = e.target?.value;
this.pcClient.updateCam(videoDevice);
});
this.muteBtn.addEventListener('click', () => {
let isCamEnabled = this.pcClient.isCamEnabled;
this.pcClient.enableCam(!isCamEnabled);
this.muteBtn.textContent = isCamEnabled ? '📵' : '📷';
});
}
private log(message: string): void {
if (!this.debugLog) return;
const entry = document.createElement('div');
entry.textContent = `${new Date().toISOString()} - ${message}`;
if (message.startsWith('User: ')) {
entry.style.color = '#2196F3';
} else if (message.startsWith('Bot: ')) {
entry.style.color = '#4CAF50';
} }
this.debugLog.appendChild(entry);
this.debugLog.scrollTop = this.debugLog.scrollHeight;
}
private initializeRTVIClient(): void { private clearAllLogs() {
const transport = new SmallWebRTCTransport(); this.debugLog!.innerText = '';
const RTVIConfig: RTVIClientOptions = { }
params: {
baseUrl: "/api/offer" private updateStatus(status: string): void {
}, if (this.statusSpan) {
transport: transport as Transport, this.statusSpan.textContent = status;
enableMic: true,
enableCam: true,
callbacks: {
onTransportStateChanged: (state) => {
this.log(`Transport state: ${state}`)
},
onConnected: () => {
this.onConnectedHandler()
},
onBotReady: () => {
this.log("Bot is ready.")
},
onDisconnected: () => {
this.onDisconnectedHandler()
},
onUserStartedSpeaking: () => {
this.log("User started speaking.")
},
onUserStoppedSpeaking: () => {
this.log("User stopped speaking.")
},
onBotStartedSpeaking: () => {
this.log("Bot started speaking.")
},
onBotStoppedSpeaking: () => {
this.log("Bot stopped speaking.")
},
onUserTranscript: (transcript) => {
if (transcript.final) {
this.log(`User transcript: ${transcript.text}`)
}
},
onBotTranscript: (transcript) => {
this.log(`Bot transcript: ${transcript.text}`)
},
onTrackStarted: (track: MediaStreamTrack, participant?: Participant) => {
if (participant?.local) {
return
}
this.onBotTrackStarted(track)
},
onServerMessage: (msg) => {
this.log(`Server message: ${msg}`)
}
},
}
RTVIConfig.customConnectHandler = () => Promise.resolve();
this.rtviClient = new RTVIClient(RTVIConfig);
this.smallWebRTCTransport = transport
} }
this.log(`Status: ${status}`);
}
private setupDOMElements(): void { private onConnectedHandler() {
this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; this.updateStatus('Connected');
this.disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; if (this.connectBtn) this.connectBtn.disabled = true;
this.muteBtn = document.getElementById('mute-btn') as HTMLButtonElement; if (this.disconnectBtn) this.disconnectBtn.disabled = false;
}
this.audioInput = document.getElementById('audio-input') as HTMLSelectElement; private onDisconnectedHandler() {
this.videoInput = document.getElementById('video-input') as HTMLSelectElement; this.updateStatus('Disconnected');
this.audioCodec = document.getElementById('audio-codec') as HTMLSelectElement; if (this.connectBtn) this.connectBtn.disabled = false;
this.videoCodec = document.getElementById('video-codec') as HTMLSelectElement; if (this.disconnectBtn) this.disconnectBtn.disabled = true;
}
this.videoElement = document.getElementById('bot-video') as HTMLVideoElement; private onBotTrackStarted(track: MediaStreamTrack) {
this.audioElement = document.getElementById('bot-audio') as HTMLAudioElement; if (track.kind === 'video') {
this.videoElement.srcObject = new MediaStream([track]);
this.debugLog = document.getElementById('debug-log'); } else {
this.statusSpan = document.getElementById('connection-status'); this.audioElement.srcObject = new MediaStream([track]);
} }
}
private setupDOMEventListeners(): void { private async populateDevices(): Promise<void> {
this.connectBtn.addEventListener("click", () => this.start()); const populateSelect = (
this.disconnectBtn.addEventListener("click", () => this.stop()); select: HTMLSelectElement,
this.audioInput.addEventListener("change", (e) => { devices: MediaDeviceInfo[]
// @ts-ignore ): void => {
let audioDevice = e.target?.value let counter = 1;
this.rtviClient.updateMic(audioDevice) devices.forEach((device) => {
}) const option = document.createElement('option');
this.videoInput.addEventListener("change", (e) => { option.value = device.deviceId;
// @ts-ignore option.text = device.label || 'Device #' + counter;
let videoDevice = e.target?.value select.appendChild(option);
this.rtviClient.updateCam(videoDevice) counter += 1;
}) });
this.muteBtn.addEventListener('click', () => { };
let isCamEnabled = this.rtviClient.isCamEnabled
this.rtviClient.enableCam(!isCamEnabled)
this.muteBtn.textContent = isCamEnabled ? '📵' : '📷';
});
try {
const audioDevices = await this.pcClient.getAllMics();
populateSelect(this.audioInput, audioDevices);
const videoDevices = await this.pcClient.getAllCams();
populateSelect(this.videoInput, videoDevices);
} catch (e) {
alert(e);
} }
}
private log(message: string): void { private async start(): Promise<void> {
if (!this.debugLog) return; this.clearAllLogs();
const entry = document.createElement('div');
entry.textContent = `${new Date().toISOString()} - ${message}`; this.connectBtn.disabled = true;
if (message.startsWith('User: ')) { this.updateStatus('Connecting');
entry.style.color = '#2196F3';
} else if (message.startsWith('Bot: ')) { this.smallWebRTCTransport.setAudioCodec(this.audioCodec.value);
entry.style.color = '#4CAF50'; this.smallWebRTCTransport.setVideoCodec(this.videoCodec.value);
} try {
this.debugLog.appendChild(entry); await this.pcClient.connect();
this.debugLog.scrollTop = this.debugLog.scrollHeight; } catch (e) {
console.log(`Failed to connect ${e}`);
this.stop();
} }
}
private clearAllLogs() { private stop(): void {
this.debugLog!.innerText = '' void this.pcClient.disconnect();
} }
private updateStatus(status: string): void {
if (this.statusSpan) {
this.statusSpan.textContent = status;
}
this.log(`Status: ${status}`);
}
private onConnectedHandler() {
this.updateStatus('Connected');
if (this.connectBtn) this.connectBtn.disabled = true;
if (this.disconnectBtn) this.disconnectBtn.disabled = false;
}
private onDisconnectedHandler() {
this.updateStatus('Disconnected');
if (this.connectBtn) this.connectBtn.disabled = false;
if (this.disconnectBtn) this.disconnectBtn.disabled = true;
}
private onBotTrackStarted(track: MediaStreamTrack) {
if (track.kind === 'video') {
this.videoElement.srcObject = new MediaStream([track]);
} else {
this.audioElement.srcObject = new MediaStream([track]);
}
}
private async populateDevices(): Promise<void> {
const populateSelect = (select: HTMLSelectElement, devices: MediaDeviceInfo[]): void => {
let counter = 1;
devices.forEach((device) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || ('Device #' + counter);
select.appendChild(option);
counter += 1;
});
};
try {
const audioDevices = await this.rtviClient.getAllMics();
populateSelect(this.audioInput, audioDevices);
const videoDevices = await this.rtviClient.getAllCams();
populateSelect(this.videoInput, videoDevices);
} catch (e) {
alert(e);
}
}
private async start(): Promise<void> {
this.clearAllLogs()
this.connectBtn.disabled = true;
this.updateStatus("Connecting")
this.smallWebRTCTransport.setAudioCodec(this.audioCodec.value)
this.smallWebRTCTransport.setVideoCodec(this.videoCodec.value)
try {
await this.rtviClient.connect()
} catch (e) {
console.log(`Failed to connect ${e}`)
this.stop()
}
}
private stop(): void {
void this.rtviClient.disconnect()
}
} }
// Create the WebRTCConnection instance // Create the WebRTCConnection instance

View File

@@ -1,40 +1,51 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chatbot</title>
</head>
<head> <body>
<meta charset="UTF-8"> <div class="container">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="status-bar">
<title>AI Chatbot</title> <div class="status">
</head> Status: <span id="connection-status">Disconnected</span>
<body>
<div class="container">
<div class="status-bar">
<div class="status">
Status: <span id="connection-status">Disconnected</span>
</div>
<div class="controls">
<button id="connect-btn">Connect</button>
<button id="disconnect-btn" disabled>Disconnect</button>
</div>
</div>
<div class="main-content">
<div class="bot-container">
<div id="bot-video-container">
</div> </div>
<audio id="bot-audio" autoplay></audio> <div class="controls">
<button id="connect-btn">Connect</button>
<button id="disconnect-btn" disabled>Disconnect</button>
</div>
</div>
<div class="main-content">
<div class="bot-container">
<div id="bot-video-container"></div>
<audio id="bot-audio" autoplay></audio>
</div>
</div>
<div class="device-bar">
<div class="device-controls">
<select id="device-selector"></select>
<button id="mic-toggle-btn">Unmute Mic</button>
</div>
<div class="text-input-container">
<input
type="text"
id="text-input"
placeholder="Type your message..." />
<button id="send-text-btn" disabled>Send</button>
</div>
</div>
<div class="debug-panel">
<h3>Debug Info</h3>
<div id="debug-log"></div>
</div> </div>
</div> </div>
<div class="debug-panel"> <script type="module" src="/src/app.js"></script>
<h3>Debug Info</h3> <link rel="stylesheet" href="/src/style.css" />
<div id="debug-log"></div> </body>
</div>
</div>
<script type="module" src="/src/app.js"></script>
<link rel="stylesheet" href="/src/style.css">
</body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.8" "@pipecat-ai/daily-transport": "^1.0.0"
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
/** /**
* RTVI Client Implementation * Pipecat Client Implementation
* *
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily). * This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
* It handles audio/video streaming and manages the connection lifecycle. * It handles audio/video streaming and manages the connection lifecycle.
@@ -16,7 +16,7 @@
* - Browser with WebRTC support * - Browser with WebRTC support
*/ */
import { RTVIClient, RTVIEvent } from '@pipecat-ai/client-js'; import { PipecatClient, RTVIEvent } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport'; import { DailyTransport } from '@pipecat-ai/daily-transport';
/** /**
@@ -26,9 +26,8 @@ import { DailyTransport } from '@pipecat-ai/daily-transport';
class ChatbotClient { class ChatbotClient {
constructor() { constructor() {
// Initialize client state // Initialize client state
this.rtviClient = null; this.pcClient = null;
this.setupDOMElements(); this.setupDOMElements();
this.setupEventListeners();
this.initializeClientAndTransport(); this.initializeClientAndTransport();
} }
@@ -42,6 +41,9 @@ class ChatbotClient {
this.statusSpan = document.getElementById('connection-status'); this.statusSpan = document.getElementById('connection-status');
this.debugLog = document.getElementById('debug-log'); this.debugLog = document.getElementById('debug-log');
this.botVideoContainer = document.getElementById('bot-video-container'); this.botVideoContainer = document.getElementById('bot-video-container');
this.deviceSelector = document.getElementById('device-selector');
this.micToggleBtn = document.getElementById('mic-toggle-btn');
this.sendTextBtn = document.getElementById('send-text-btn');
// Create an audio element for bot's voice output // Create an audio element for bot's voice output
this.botAudio = document.createElement('audio'); this.botAudio = document.createElement('audio');
@@ -54,25 +56,78 @@ class ChatbotClient {
* Set up event listeners for connect/disconnect buttons * Set up event listeners for connect/disconnect buttons
*/ */
setupEventListeners() { setupEventListeners() {
this.connectBtn.addEventListener('click', () => this.connect()); this.connectBtn.addEventListener('click', () => {
console.log('click');
this.connect();
});
this.disconnectBtn.addEventListener('click', () => this.disconnect()); this.disconnectBtn.addEventListener('click', () => this.disconnect());
// Populate device selector
this.pcClient.getAllMics().then((mics) => {
console.log('Available mics:', mics);
mics.forEach((device) => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `Microphone ${device.deviceId}`;
this.deviceSelector.appendChild(option);
});
});
this.deviceSelector.addEventListener('change', (event) => {
const selectedDeviceId = event.target.value;
console.log('Selected device ID:', selectedDeviceId);
this.pcClient.updateMic(selectedDeviceId);
});
// Handle mic mute/unmute toggle
const micToggleBtn = document.getElementById('mic-toggle-btn');
micToggleBtn.addEventListener('click', async () => {
if (this.pcClient.state === 'disconnected') {
await this.pcClient.initDevices();
} else {
this.pcClient.enableMic(!this.pcClient.isMicEnabled);
}
});
const textInput = document.getElementById('text-input');
const sendTextToLLM = () => {
this.sendTextBtn.disabled = true; // Disable button to prevent multiple clicks
const text = textInput.value.trim();
if (text) {
void this.pcClient.appendToContext({
role: 'user',
content: text,
run_immediately: true,
});
}
textInput.value = ''; // Clear the input
this.sendTextBtn.disabled = false; // Re-enable button after sending
};
this.sendTextBtn.addEventListener('click', sendTextToLLM);
// Also handle Enter key in the input
textInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendTextToLLM();
}
});
} }
updateMicToggleButton(micEnabled) {
console.log('Mic enabled:', micEnabled, this.pcClient?.isMicEnabled);
this.micToggleBtn.textContent = micEnabled ? 'Mute Mic' : 'Unmute Mic';
}
/** /**
* Set up the RTVI client and Daily transport * Set up the Pipecat client and Daily transport
*/ */
initializeClientAndTransport() { async initializeClientAndTransport() {
// Initialize the RTVI client with a DailyTransport and our configuration console.log('Initializing Pipecat client and transport...');
this.rtviClient = new RTVIClient({ // Initialize the Pipecat client with a DailyTransport and our configuration
this.pcClient = new PipecatClient({
transport: new DailyTransport(), transport: new DailyTransport(),
params: { enableMic: true,
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: {
connect: '/connect',
},
},
enableMic: true, // Enable microphone for user input
enableCam: false, enableCam: false,
callbacks: { callbacks: {
// Handle connection state changes // Handle connection state changes
@@ -86,7 +141,9 @@ class ChatbotClient {
this.updateStatus('Disconnected'); this.updateStatus('Disconnected');
this.connectBtn.disabled = false; this.connectBtn.disabled = false;
this.disconnectBtn.disabled = true; this.disconnectBtn.disabled = true;
this.sendTextBtn.disabled = true;
this.log('Client disconnected'); this.log('Client disconnected');
this.updateMicToggleButton(false);
}, },
// Handle transport state changes // Handle transport state changes
onTransportStateChanged: (state) => { onTransportStateChanged: (state) => {
@@ -106,6 +163,7 @@ class ChatbotClient {
onBotReady: (data) => { onBotReady: (data) => {
this.log(`Bot ready: ${JSON.stringify(data)}`); this.log(`Bot ready: ${JSON.stringify(data)}`);
this.setupMediaTracks(); this.setupMediaTracks();
this.sendTextBtn.disabled = false;
}, },
// Transcript events // Transcript events
onUserTranscript: (data) => { onUserTranscript: (data) => {
@@ -121,14 +179,20 @@ class ChatbotClient {
onMessageError: (error) => { onMessageError: (error) => {
console.log('Message error:', error); console.log('Message error:', error);
}, },
onMicUpdated: (data) => {
console.log('Mic updated:', data);
this.deviceSelector.value = data.deviceId;
},
onError: (error) => { onError: (error) => {
console.log('Error:', JSON.stringify(error)); console.log('Error:', JSON.stringify(error));
}, },
}, },
}); });
window.client = this; // Expose client globally for debugging
// Set up listeners for media track events // Set up listeners for media track events
this.setupTrackListeners(); this.setupTrackListeners();
this.setupEventListeners();
} }
/** /**
@@ -163,10 +227,10 @@ class ChatbotClient {
* This is called when the bot is ready or when the transport state changes to ready * This is called when the bot is ready or when the transport state changes to ready
*/ */
setupMediaTracks() { setupMediaTracks() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Get current tracks from the client // Get current tracks from the client
const tracks = this.rtviClient.tracks(); const tracks = this.pcClient.tracks();
// Set up any available bot tracks // Set up any available bot tracks
if (tracks.bot?.audio) { if (tracks.bot?.audio) {
@@ -182,27 +246,34 @@ class ChatbotClient {
* This handles new tracks being added during the session * This handles new tracks being added during the session
*/ */
setupTrackListeners() { setupTrackListeners() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks
if (!participant?.local) { if (!participant?.local) {
if (track.kind === 'audio') { if (track.kind === 'audio') {
this.setupAudioTrack(track); this.setupAudioTrack(track);
} else if (track.kind === 'video') { } else if (track.kind === 'video') {
this.setupVideoTrack(track); this.setupVideoTrack(track);
} }
} else if (track.kind === 'audio') {
console.log(`Local audio track started: `, this.pcClient.tracks());
// If local audio track starts, update mic
this.updateMicToggleButton(true);
} }
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log( this.log(
`Track stopped event: ${track.kind} from ${ `Track stopped event: ${track.kind} from ${
participant?.name || 'unknown' participant ? (participant.local ? 'local' : 'bot') : 'unknown'
}` }`
); );
if (participant?.local && track.kind === 'audio') {
// If local audio track stops, update mic toggle button
this.updateMicToggleButton(false);
}
}); });
} }
@@ -251,17 +322,16 @@ class ChatbotClient {
/** /**
* Initialize and connect to the bot * Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection * This sets up the Pipecat client, initializes devices, and establishes the connection
*/ */
async connect() { async connect() {
try { try {
// Initialize audio/video devices
this.log('Initializing devices...');
await this.rtviClient.initDevices();
// Connect to the bot // Connect to the bot
this.log('Connecting to bot...'); this.log('Connecting to bot...');
await this.rtviClient.connect(); await this.pcClient.connect({
endpoint: 'http://localhost:7860/connect',
timeout: 25000,
});
this.log('Connection complete'); this.log('Connection complete');
} catch (error) { } catch (error) {
@@ -271,9 +341,9 @@ class ChatbotClient {
this.updateStatus('Error'); this.updateStatus('Error');
// Clean up if there's an error // Clean up if there's an error
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
} catch (disconnectError) { } catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError.message}`); this.log(`Error during disconnect: ${disconnectError.message}`);
} }
@@ -285,10 +355,10 @@ class ChatbotClient {
* Disconnect from the bot and clean up media resources * Disconnect from the bot and clean up media resources
*/ */
async disconnect() { async disconnect() {
if (this.rtviClient) { if (this.pcClient) {
try { try {
// Disconnect the RTVI client // Disconnect the Pipecat client
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
// Clean up audio // Clean up audio
if (this.botAudio.srcObject) { if (this.botAudio.srcObject) {

View File

@@ -10,7 +10,8 @@ body {
margin: 0 auto; margin: 0 auto;
} }
.status-bar { .status-bar,
.device-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -20,7 +21,24 @@ body {
margin-bottom: 20px; margin-bottom: 20px;
} }
.controls button { .device-bar {
flex-direction: column;
gap: 10px;
}
.controls,
.device-controls {
display: flex;
align-items: center;
gap: 10px; /* Adds spacing between elements */
}
.device-controls {
margin-left: auto;
}
.controls button,
.device-controls button {
padding: 8px 16px; padding: 8px 16px;
margin-left: 10px; margin-left: 10px;
border: none; border: none;
@@ -28,6 +46,56 @@ body {
cursor: pointer; cursor: pointer;
} }
#bot-selector,
#device-selector {
padding: 8px 16px;
padding-right: 40px;
border: none;
border-radius: 4px;
background-color: #6c757d; /* Gray background */
color: white; /* White text */
cursor: pointer;
appearance: none; /* Removes default browser styling for dropdowns */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); /* Custom arrow */
background-repeat: no-repeat;
background-position: right 8px center; /* Position the arrow */
}
#bot-selector:focus,
#device-selector:focus {
outline: none;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); /* Add a subtle focus effect */
}
.text-input-container {
display: flex;
gap: 8px;
margin-left: 10px;
width: 100%;
flex: 1;
}
#text-input {
flex: 1;
padding: 8px 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
min-width: 200px;
}
#send-text-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
flex-shrink: 0;
}
#send-text-btn:hover {
background-color: #0056b3;
}
#connect-btn { #connect-btn {
background-color: #4caf50; background-color: #4caf50;
color: white; color: white;
@@ -38,6 +106,9 @@ body {
color: white; color: white;
} }
#mic-toggle-btn {
}
button:disabled { button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/client-react": "^0.3.5", "@pipecat-ai/client-react": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.8", "@pipecat-ai/daily-transport": "^1.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },

View File

@@ -1,22 +1,22 @@
import { import {
RTVIClientAudio, PipecatClientAudio,
RTVIClientVideo, PipecatClientVideo,
useRTVIClientTransportState, usePipecatClientTransportState,
} from '@pipecat-ai/client-react'; } from '@pipecat-ai/client-react';
import { RTVIProvider } from './providers/RTVIProvider'; import { PipecatProvider } from './providers/PipecatProvider';
import { ConnectButton } from './components/ConnectButton'; import { ConnectButton } from './components/ConnectButton';
import { StatusDisplay } from './components/StatusDisplay'; import { StatusDisplay } from './components/StatusDisplay';
import { DebugDisplay } from './components/DebugDisplay'; import { DebugDisplay } from './components/DebugDisplay';
import './App.css'; import './App.css';
function BotVideo() { function BotVideo() {
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
const isConnected = transportState !== 'disconnected'; const isConnected = transportState !== 'disconnected';
return ( return (
<div className="bot-container"> <div className="bot-container">
<div className="video-container"> <div className="video-container">
{isConnected && <RTVIClientVideo participant="bot" fit="cover" />} {isConnected && <PipecatClientVideo participant="bot" fit="cover" />}
</div> </div>
</div> </div>
); );
@@ -35,16 +35,16 @@ function AppContent() {
</div> </div>
<DebugDisplay /> <DebugDisplay />
<RTVIClientAudio /> <PipecatClientAudio />
</div> </div>
); );
} }
function App() { function App() {
return ( return (
<RTVIProvider> <PipecatProvider>
<AppContent /> <AppContent />
</RTVIProvider> </PipecatProvider>
); );
} }

View File

@@ -1,16 +1,16 @@
import { import {
useRTVIClient, usePipecatClient,
useRTVIClientTransportState, usePipecatClientTransportState,
} from '@pipecat-ai/client-react'; } from '@pipecat-ai/client-react';
export function ConnectButton() { export function ConnectButton() {
const client = useRTVIClient(); const client = usePipecatClient();
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
const isConnected = ['connected', 'ready'].includes(transportState); const isConnected = ['connected', 'ready'].includes(transportState);
const handleClick = async () => { const handleClick = async () => {
if (!client) { if (!client) {
console.error('RTVI client is not initialized'); console.error('Pipecat client is not initialized');
return; return;
} }
@@ -18,7 +18,7 @@ export function ConnectButton() {
if (isConnected) { if (isConnected) {
await client.disconnect(); await client.disconnect();
} else { } else {
await client.connect(); await client.connect({ endpoint: 'http://localhost:7860/connect' });
} }
} catch (error) { } catch (error) {
console.error('Connection error:', error); console.error('Connection error:', error);

View File

@@ -6,12 +6,12 @@ import {
TranscriptData, TranscriptData,
BotLLMTextData, BotLLMTextData,
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { useRTVIClient, useRTVIClientEvent } from '@pipecat-ai/client-react'; import { usePipecatClient, useRTVIClientEvent } from '@pipecat-ai/client-react';
import './DebugDisplay.css'; import './DebugDisplay.css';
export function DebugDisplay() { export function DebugDisplay() {
const debugLogRef = useRef<HTMLDivElement>(null); const debugLogRef = useRef<HTMLDivElement>(null);
const client = useRTVIClient(); const client = usePipecatClient();
const log = useCallback((message: string) => { const log = useCallback((message: string) => {
if (!debugLogRef.current) return; if (!debugLogRef.current) return;

View File

@@ -1,7 +1,7 @@
import { useRTVIClientTransportState } from '@pipecat-ai/client-react'; import { usePipecatClientTransportState } from '@pipecat-ai/client-react';
export function StatusDisplay() { export function StatusDisplay() {
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
return ( return (
<div className="status"> <div className="status">

View File

@@ -0,0 +1,16 @@
import { type PropsWithChildren } from 'react';
import { PipecatClient } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport';
import { PipecatClientProvider } from '@pipecat-ai/client-react';
const client = new PipecatClient({
transport: new DailyTransport(),
enableMic: true,
enableCam: false,
});
export function PipecatProvider({ children }: PropsWithChildren) {
return (
<PipecatClientProvider client={client}>{children}</PipecatClientProvider>
);
}

View File

@@ -1,22 +0,0 @@
import { type PropsWithChildren } from 'react';
import { RTVIClient } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport';
import { RTVIClientProvider } from '@pipecat-ai/client-react';
const transport = new DailyTransport();
const client = new RTVIClient({
transport,
params: {
baseUrl: 'http://localhost:7860',
endpoints: {
connect: '/connect',
},
},
enableMic: true,
enableCam: false,
});
export function RTVIProvider({ children }: PropsWithChildren) {
return <RTVIClientProvider client={client}>{children}</RTVIClientProvider>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@
"vite": "^6.0.2" "vite": "^6.0.2"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.4.0", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/websocket-transport": "^0.4.2" "@pipecat-ai/websocket-transport": "^1.0.0"
} }
} }

View File

@@ -5,21 +5,22 @@
*/ */
import { import {
RTVIClient, BotLLMTextData,
RTVIClientOptions, Participant,
RTVIEvent, PipecatClient,
PipecatClientOptions,
RTVIEvent, RTVIMessage, TranscriptData,
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { import {
WebSocketTransport, WebSocketTransport,
TwilioSerializer, TwilioSerializer,
} from "@pipecat-ai/websocket-transport"; } from '@pipecat-ai/websocket-transport';
class WebsocketClientApp { class WebsocketClientApp {
private static STREAM_SID = 'ws_mock_stream_sid';
private static CALL_SID = 'ws_mock_call_sid';
private static STREAM_SID = "ws_mock_stream_sid" private rtviClient: PipecatClient | null = null;
private static CALL_SID = "ws_mock_call_sid"
private rtviClient: RTVIClient | null = null;
private connectBtn: HTMLButtonElement | null = null; private connectBtn: HTMLButtonElement | null = null;
private disconnectBtn: HTMLButtonElement | null = null; private disconnectBtn: HTMLButtonElement | null = null;
private statusSpan: HTMLElement | null = null; private statusSpan: HTMLElement | null = null;
@@ -38,8 +39,12 @@ class WebsocketClientApp {
* Set up references to DOM elements and create necessary media elements * Set up references to DOM elements and create necessary media elements
*/ */
private setupDOMElements(): void { private setupDOMElements(): void {
this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; this.connectBtn = document.getElementById(
this.disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 'connect-btn'
) as HTMLButtonElement;
this.disconnectBtn = document.getElementById(
'disconnect-btn'
) as HTMLButtonElement;
this.statusSpan = document.getElementById('connection-status'); this.statusSpan = document.getElementById('connection-status');
this.debugLog = document.getElementById('debug-log'); this.debugLog = document.getElementById('debug-log');
} }
@@ -80,13 +85,23 @@ class WebsocketClientApp {
} }
private async emulateTwilioMessages() { private async emulateTwilioMessages() {
const connectedMessage={"event": "connected", "protocol": "Call", "version": "1.0.0"} const connectedMessage = {
event: 'connected',
protocol: 'Call',
version: '1.0.0',
};
const websocketTransport = this.rtviClient?.transport as WebSocketTransport const websocketTransport = this.rtviClient?.transport as WebSocketTransport;
void websocketTransport?.sendRawMessage(connectedMessage) void websocketTransport?.sendRawMessage(connectedMessage);
const startMessage={"event": "start", "start": {"streamSid": WebsocketClientApp.STREAM_SID, "callSid": WebsocketClientApp.CALL_SID}} const startMessage = {
void websocketTransport?.sendRawMessage(startMessage) event: 'start',
start: {
streamSid: WebsocketClientApp.STREAM_SID,
callSid: WebsocketClientApp.CALL_SID,
},
};
void websocketTransport?.sendRawMessage(startMessage);
} }
/** /**
@@ -109,7 +124,7 @@ class WebsocketClientApp {
if (!this.rtviClient) return; if (!this.rtviClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.rtviClient.on(RTVIEvent.TrackStarted, (track: MediaStreamTrack, participant?: Participant) => {
// Only handle non-local (bot) tracks // Only handle non-local (bot) tracks
if (!participant?.local && track.kind === 'audio') { if (!participant?.local && track.kind === 'audio') {
this.setupAudioTrack(track); this.setupAudioTrack(track);
@@ -117,8 +132,10 @@ class WebsocketClientApp {
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.rtviClient.on(RTVIEvent.TrackStopped, (track: MediaStreamTrack, participant?: Participant) => {
this.log(`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`); this.log(
`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`
);
}); });
} }
@@ -128,7 +145,10 @@ class WebsocketClientApp {
*/ */
private setupAudioTrack(track: MediaStreamTrack): void { private setupAudioTrack(track: MediaStreamTrack): void {
this.log('Setting up audio track'); this.log('Setting up audio track');
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
const oldTrack = this.botAudio.srcObject.getAudioTracks()[0]; const oldTrack = this.botAudio.srcObject.getAudioTracks()[0];
if (oldTrack?.id === track.id) return; if (oldTrack?.id === track.id) return;
} }
@@ -143,23 +163,19 @@ class WebsocketClientApp {
try { try {
const startTime = Date.now(); const startTime = Date.now();
const transport = new WebSocketTransport({ const ws_opts = {
serializer: new TwilioSerializer(), serializer: new TwilioSerializer(),
recorderSampleRate: 8000, recorderSampleRate: 8000,
playerSampleRate: 8000 playerSampleRate: 8000,
}); ws_url: 'http://localhost:8765/ws',
const RTVIConfig: RTVIClientOptions = { };
transport, const RTVIConfig: PipecatClientOptions = {
params: { transport: new WebSocketTransport(ws_opts),
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:8765',
endpoints: { connect: '/' },
},
enableMic: true, enableMic: true,
enableCam: false, enableCam: false,
callbacks: { callbacks: {
onConnected: () => { onConnected: () => {
this.emulateTwilioMessages() this.emulateTwilioMessages();
this.updateStatus('Connected'); this.updateStatus('Connected');
if (this.connectBtn) this.connectBtn.disabled = true; if (this.connectBtn) this.connectBtn.disabled = true;
if (this.disconnectBtn) this.disconnectBtn.disabled = false; if (this.disconnectBtn) this.disconnectBtn.disabled = false;
@@ -170,27 +186,21 @@ class WebsocketClientApp {
if (this.disconnectBtn) this.disconnectBtn.disabled = true; if (this.disconnectBtn) this.disconnectBtn.disabled = true;
this.log('Client disconnected'); this.log('Client disconnected');
}, },
onBotReady: (data) => { onBotReady: (data: any) => {
this.log(`Bot ready: ${JSON.stringify(data)}`); this.log(`Bot ready: ${JSON.stringify(data)}`);
this.setupMediaTracks(); this.setupMediaTracks();
}, },
onUserTranscript: (data) => { onUserTranscript: (data: TranscriptData) => {
if (data.final) { if (data.final) {
this.log(`User: ${data.text}`); this.log(`User: ${data.text}`);
} }
}, },
onBotTranscript: (data) => this.log(`Bot: ${data.text}`), onBotTranscript: (data: BotLLMTextData) => this.log(`Bot: ${data.text}`),
onMessageError: (error) => console.error('Message error:', error), onMessageError: (error: RTVIMessage) => console.error('Message error:', error),
onError: (error) => console.error('Error:', error), onError: (error: RTVIMessage) => console.error('Error:', error),
}, },
} };
// @ts-ignore this.rtviClient = new PipecatClient(RTVIConfig);
RTVIConfig.customConnectHandler = () => Promise.resolve(
{
ws_url: "/ws",
}
);
this.rtviClient = new RTVIClient(RTVIConfig);
this.setupTrackListeners(); this.setupTrackListeners();
this.log('Initializing devices...'); this.log('Initializing devices...');
@@ -223,8 +233,13 @@ class WebsocketClientApp {
try { try {
await this.rtviClient.disconnect(); await this.rtviClient.disconnect();
this.rtviClient = null; this.rtviClient = null;
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject.getAudioTracks().forEach((track) => track.stop()); this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
this.botAudio.srcObject
.getAudioTracks()
.forEach((track) => track.stop());
this.botAudio.srcObject = null; this.botAudio.srcObject = null;
} }
} catch (error) { } catch (error) {
@@ -232,7 +247,6 @@ class WebsocketClientApp {
} }
} }
} }
} }
declare global { declare global {

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.4.0", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/websocket-transport": "^0.4.2", "@pipecat-ai/websocket-transport": "^1.0.0",
"protobufjs": "^7.4.0" "protobufjs": "^7.4.0"
} }
} }

View File

@@ -5,7 +5,7 @@
*/ */
/** /**
* RTVI Client Implementation * Pipecat Client Implementation
* *
* This client connects to an RTVI-compatible bot server using WebSocket. * This client connects to an RTVI-compatible bot server using WebSocket.
* *
@@ -14,16 +14,14 @@
*/ */
import { import {
RTVIClient, PipecatClient,
RTVIClientOptions, PipecatClientOptions,
RTVIEvent, RTVIEvent,
} from '@pipecat-ai/client-js'; } from '@pipecat-ai/client-js';
import { import { WebSocketTransport } from '@pipecat-ai/websocket-transport';
WebSocketTransport
} from "@pipecat-ai/websocket-transport";
class WebsocketClientApp { class WebsocketClientApp {
private rtviClient: RTVIClient | null = null; private pcClient: PipecatClient | null = null;
private connectBtn: HTMLButtonElement | null = null; private connectBtn: HTMLButtonElement | null = null;
private disconnectBtn: HTMLButtonElement | null = null; private disconnectBtn: HTMLButtonElement | null = null;
private statusSpan: HTMLElement | null = null; private statusSpan: HTMLElement | null = null;
@@ -31,7 +29,7 @@ class WebsocketClientApp {
private botAudio: HTMLAudioElement; private botAudio: HTMLAudioElement;
constructor() { constructor() {
console.log("WebsocketClientApp"); console.log('WebsocketClientApp');
this.botAudio = document.createElement('audio'); this.botAudio = document.createElement('audio');
this.botAudio.autoplay = true; this.botAudio.autoplay = true;
//this.botAudio.playsInline = true; //this.botAudio.playsInline = true;
@@ -45,8 +43,12 @@ class WebsocketClientApp {
* Set up references to DOM elements and create necessary media elements * Set up references to DOM elements and create necessary media elements
*/ */
private setupDOMElements(): void { private setupDOMElements(): void {
this.connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; this.connectBtn = document.getElementById(
this.disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 'connect-btn'
) as HTMLButtonElement;
this.disconnectBtn = document.getElementById(
'disconnect-btn'
) as HTMLButtonElement;
this.statusSpan = document.getElementById('connection-status'); this.statusSpan = document.getElementById('connection-status');
this.debugLog = document.getElementById('debug-log'); this.debugLog = document.getElementById('debug-log');
} }
@@ -91,8 +93,8 @@ class WebsocketClientApp {
* This is called when the bot is ready or when the transport state changes to ready * This is called when the bot is ready or when the transport state changes to ready
*/ */
setupMediaTracks() { setupMediaTracks() {
if (!this.rtviClient) return; if (!this.pcClient) return;
const tracks = this.rtviClient.tracks(); const tracks = this.pcClient.tracks();
if (tracks.bot?.audio) { if (tracks.bot?.audio) {
this.setupAudioTrack(tracks.bot.audio); this.setupAudioTrack(tracks.bot.audio);
} }
@@ -103,10 +105,10 @@ class WebsocketClientApp {
* This handles new tracks being added during the session * This handles new tracks being added during the session
*/ */
setupTrackListeners() { setupTrackListeners() {
if (!this.rtviClient) return; if (!this.pcClient) return;
// Listen for new tracks starting // Listen for new tracks starting
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStarted, (track, participant) => {
// Only handle non-local (bot) tracks // Only handle non-local (bot) tracks
if (!participant?.local && track.kind === 'audio') { if (!participant?.local && track.kind === 'audio') {
this.setupAudioTrack(track); this.setupAudioTrack(track);
@@ -114,8 +116,10 @@ class WebsocketClientApp {
}); });
// Listen for tracks stopping // Listen for tracks stopping
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => { this.pcClient.on(RTVIEvent.TrackStopped, (track, participant) => {
this.log(`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`); this.log(
`Track stopped: ${track.kind} from ${participant?.name || 'unknown'}`
);
}); });
} }
@@ -125,7 +129,10 @@ class WebsocketClientApp {
*/ */
private setupAudioTrack(track: MediaStreamTrack): void { private setupAudioTrack(track: MediaStreamTrack): void {
this.log('Setting up audio track'); this.log('Setting up audio track');
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
const oldTrack = this.botAudio.srcObject.getAudioTracks()[0]; const oldTrack = this.botAudio.srcObject.getAudioTracks()[0];
if (oldTrack?.id === track.id) return; if (oldTrack?.id === track.id) return;
} }
@@ -134,21 +141,15 @@ class WebsocketClientApp {
/** /**
* Initialize and connect to the bot * Initialize and connect to the bot
* This sets up the RTVI client, initializes devices, and establishes the connection * This sets up the Pipecat client, initializes devices, and establishes the connection
*/ */
public async connect(): Promise<void> { public async connect(): Promise<void> {
try { try {
const startTime = Date.now(); const startTime = Date.now();
//const transport = new DailyTransport(); //const transport = new DailyTransport();
const transport = new WebSocketTransport(); const PipecatConfig: PipecatClientOptions = {
const RTVIConfig: RTVIClientOptions = { transport: new WebSocketTransport(),
transport,
params: {
// The baseURL and endpoint of your bot server that the client will connect to
baseUrl: 'http://localhost:7860',
endpoints: { connect: '/connect' },
},
enableMic: true, enableMic: true,
enableCam: false, enableCam: false,
callbacks: { callbacks: {
@@ -176,15 +177,20 @@ class WebsocketClientApp {
onMessageError: (error) => console.error('Message error:', error), onMessageError: (error) => console.error('Message error:', error),
onError: (error) => console.error('Error:', error), onError: (error) => console.error('Error:', error),
}, },
} };
this.rtviClient = new RTVIClient(RTVIConfig); this.pcClient = new PipecatClient(PipecatConfig);
// @ts-ignore
window.pcClient = this.pcClient; // Expose for debugging
this.setupTrackListeners(); this.setupTrackListeners();
this.log('Initializing devices...'); this.log('Initializing devices...');
await this.rtviClient.initDevices(); await this.pcClient.initDevices();
this.log('Connecting to bot...'); this.log('Connecting to bot...');
await this.rtviClient.connect(); await this.pcClient.connect({
// The baseURL and endpoint of your bot server that the client will connect to
endpoint: 'http://localhost:7860/connect',
});
const timeTaken = Date.now() - startTime; const timeTaken = Date.now() - startTime;
this.log(`Connection complete, timeTaken: ${timeTaken}`); this.log(`Connection complete, timeTaken: ${timeTaken}`);
@@ -192,9 +198,9 @@ class WebsocketClientApp {
this.log(`Error connecting: ${(error as Error).message}`); this.log(`Error connecting: ${(error as Error).message}`);
this.updateStatus('Error'); this.updateStatus('Error');
// Clean up if there's an error // Clean up if there's an error
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
} catch (disconnectError) { } catch (disconnectError) {
this.log(`Error during disconnect: ${disconnectError}`); this.log(`Error during disconnect: ${disconnectError}`);
} }
@@ -206,12 +212,17 @@ class WebsocketClientApp {
* Disconnect from the bot and clean up media resources * Disconnect from the bot and clean up media resources
*/ */
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
if (this.rtviClient) { if (this.pcClient) {
try { try {
await this.rtviClient.disconnect(); await this.pcClient.disconnect();
this.rtviClient = null; this.pcClient = null;
if (this.botAudio.srcObject && "getAudioTracks" in this.botAudio.srcObject) { if (
this.botAudio.srcObject.getAudioTracks().forEach((track) => track.stop()); this.botAudio.srcObject &&
'getAudioTracks' in this.botAudio.srcObject
) {
this.botAudio.srcObject
.getAudioTracks()
.forEach((track) => track.stop());
this.botAudio.srcObject = null; this.botAudio.srcObject = null;
} }
} catch (error) { } catch (error) {
@@ -219,7 +230,6 @@ class WebsocketClientApp {
} }
} }
} }
} }
declare global { declare global {

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,12 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@pipecat-ai/client-js": "^0.3.5", "@pipecat-ai/client-js": "^1.0.0",
"@pipecat-ai/client-react": "^0.3.5", "@pipecat-ai/client-react": "^1.0.0",
"@pipecat-ai/daily-transport": "^0.3.10", "@pipecat-ai/daily-transport": "^1.0.0",
"@tabler/icons-react": "^3.31.0", "@tabler/icons-react": "^3.31.0",
"@tailwindcss/postcss": "^4.1.3", "@tailwindcss/postcss": "^4.1.3",
"jotai": "^2.12.5",
"js-confetti": "^0.12.0", "js-confetti": "^0.12.0",
"next": "15.2.4", "next": "15.2.4",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,16 +1,26 @@
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { import {
useRTVIClient, usePipecatClient,
useRTVIClientTransportState, usePipecatClientTransportState,
} from '@pipecat-ai/client-react'; } from '@pipecat-ai/client-react';
import { CONNECTION_STATES } from '@/constants/gameConstants'; import { CONNECTION_STATES } from '@/constants/gameConstants';
import { useConfigurationSettings } from '@/contexts/Configuration';
// Get the API base URL from environment variables
// Default to "/api" if not specified
// "/api" is the default for Next.js API routes and used
// for the Pipecat Cloud deployed agent
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api';
console.log('Using API base URL:', API_BASE_URL);
export function useConnectionState( export function useConnectionState(
onConnected?: () => void, onConnected?: () => void,
onDisconnected?: () => void onDisconnected?: () => void
) { ) {
const client = useRTVIClient(); const client = usePipecatClient();
const transportState = useRTVIClientTransportState(); const transportState = usePipecatClientTransportState();
const config = useConfigurationSettings();
const isConnected = CONNECTION_STATES.ACTIVE.includes(transportState); const isConnected = CONNECTION_STATES.ACTIVE.includes(transportState);
const isConnecting = CONNECTION_STATES.CONNECTING.includes(transportState); const isConnecting = CONNECTION_STATES.CONNECTING.includes(transportState);
@@ -35,12 +45,17 @@ export function useConnectionState(
if (isConnected) { if (isConnected) {
await client.disconnect(); await client.disconnect();
} else { } else {
await client.connect(); await client.connect({
endpoint: `${API_BASE_URL}/connect`,
requestData: {
personality: config.personality,
},
});
} }
} catch (error) { } catch (error) {
console.error('Connection error:', error); console.error('Connection error:', error);
} }
}, [client, isConnected]); }, [client, config, isConnected]);
return { return {
isConnected, isConnected,

View File

@@ -1,15 +1,15 @@
import { ConfigurationProvider } from "@/contexts/Configuration"; import { ConfigurationProvider } from '@/contexts/Configuration';
import { RTVIProvider } from "@/providers/RTVIProvider"; import { PipecatProvider } from '@/providers/PipecatProvider';
import { RTVIClientAudio } from "@pipecat-ai/client-react"; import { PipecatClientAudio } from '@pipecat-ai/client-react';
import type { AppProps } from "next/app"; import type { AppProps } from 'next/app';
import { Nunito } from "next/font/google"; import { Nunito } from 'next/font/google';
import Head from "next/head"; import Head from 'next/head';
import "../styles/globals.css"; import '../styles/globals.css';
const nunito = Nunito({ const nunito = Nunito({
subsets: ["latin"], subsets: ['latin'],
display: "swap", display: 'swap',
variable: "--font-sans", variable: '--font-sans',
}); });
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
@@ -21,10 +21,10 @@ export default function App({ Component, pageProps }: AppProps) {
</Head> </Head>
<main className={`${nunito.variable}`}> <main className={`${nunito.variable}`}>
<ConfigurationProvider> <ConfigurationProvider>
<RTVIProvider> <PipecatProvider>
<RTVIClientAudio /> <PipecatClientAudio />
<Component {...pageProps} /> <Component {...pageProps} />
</RTVIProvider> </PipecatProvider>
</ConfigurationProvider> </ConfigurationProvider>
</main> </main>
</> </>

View File

@@ -1,11 +1,11 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
) { ) {
if (req.method !== "POST") { if (req.method !== 'POST') {
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: 'Method not allowed' });
} }
try { try {
@@ -15,16 +15,16 @@ export default async function handler(
if (!personality) { if (!personality) {
return res return res
.status(400) .status(400)
.json({ error: "Missing required configuration parameters" }); .json({ error: 'Missing required configuration parameters' });
} }
const response = await fetch( const response = await fetch(
`https://api.pipecat.daily.co/v1/public/${process.env.AGENT_NAME}/start`, `https://api.pipecat.daily.co/v1/public/${process.env.AGENT_NAME}/start`,
{ {
method: "POST", method: 'POST',
headers: { headers: {
Authorization: `Bearer ${process.env.PIPECAT_CLOUD_API_KEY}`, Authorization: `Bearer ${process.env.PIPECAT_CLOUD_API_KEY}`,
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
createDailyRoom: true, createDailyRoom: true,
@@ -37,15 +37,15 @@ export default async function handler(
const data = await response.json(); const data = await response.json();
console.log("Response from API:", JSON.stringify(data, null, 2)); console.log('Response from API:', JSON.stringify(data, null, 2));
// Transform the response to match what RTVI client expects // Transform the response to match what Pipecat client expects
return res.status(200).json({ return res.status(200).json({
room_url: data.dailyRoom, room_url: data.dailyRoom,
token: data.dailyToken, token: data.dailyToken,
}); });
} catch (error) { } catch (error) {
console.error("Error starting agent:", error); console.error('Error starting agent:', error);
return res.status(500).json({ error: "Failed to start agent" }); return res.status(500).json({ error: 'Failed to start agent' });
} }
} }

View File

@@ -0,0 +1,43 @@
'use client';
import { PipecatClient } from '@pipecat-ai/client-js';
import { DailyTransport } from '@pipecat-ai/daily-transport';
import { PipecatClientProvider } from '@pipecat-ai/client-react';
import { PropsWithChildren, useEffect, useState, useRef } from 'react';
export function PipecatProvider({ children }: PropsWithChildren) {
const [client, setClient] = useState<PipecatClient | null>(null);
const clientCreated = useRef(false);
useEffect(() => {
// Only create the client once
if (clientCreated.current) return;
const pcClient = new PipecatClient({
transport: new DailyTransport(),
enableMic: true,
enableCam: false,
});
setClient(pcClient);
clientCreated.current = true;
// Cleanup when component unmounts
return () => {
if (pcClient) {
pcClient.disconnect().catch((err) => {
console.error('Error disconnecting client:', err);
});
}
clientCreated.current = false;
};
}, []);
if (!client) {
return null;
}
return (
<PipecatClientProvider client={client}>{children}</PipecatClientProvider>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
import { RTVIClient } from "@pipecat-ai/client-js";
import { DailyTransport } from "@pipecat-ai/daily-transport";
import { RTVIClientProvider } from "@pipecat-ai/client-react";
import { PropsWithChildren, useEffect, useState, useRef } from "react";
import { useConfigurationSettings } from "@/contexts/Configuration";
// Get the API base URL from environment variables
// Default to "/api" if not specified
// "/api" is the default for Next.js API routes and used
// for the Pipecat Cloud deployed agent
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "/api";
console.log("Using API base URL:", API_BASE_URL);
export function RTVIProvider({ children }: PropsWithChildren) {
const [client, setClient] = useState<RTVIClient | null>(null);
const config = useConfigurationSettings();
const clientCreated = useRef(false);
useEffect(() => {
// Only create the client once
if (clientCreated.current) return;
const transport = new DailyTransport();
const rtviClient = new RTVIClient({
transport,
params: {
baseUrl: API_BASE_URL,
endpoints: {
connect: "/connect",
},
requestData: {
personality: config.personality,
},
},
enableMic: true,
enableCam: false,
});
setClient(rtviClient);
clientCreated.current = true;
// Cleanup when component unmounts
return () => {
if (rtviClient) {
rtviClient.disconnect().catch((err) => {
console.error("Error disconnecting client:", err);
});
}
clientCreated.current = false;
};
}, []);
// Update the connectParams when config changes
useEffect(() => {
if (!client) return;
// Update the connect params without recreating the client
client.params.requestData = {
personality: config.personality,
};
}, [client, config.personality]);
if (!client) {
return null;
}
return <RTVIClientProvider client={client}>{children}</RTVIClientProvider>;
}

View File

@@ -4,6 +4,7 @@
# 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 sys import sys
@@ -198,16 +199,15 @@ async def bot(args: DailySessionArguments):
# Local development # Local development
async def local_daily(): async def local_daily(args: DailySessionArguments):
"""Daily transport for local development.""" """Daily transport for local development."""
from runner import configure # from runner import configure
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
(room_url, token) = await configure(session)
transport = DailyTransport( transport = DailyTransport(
room_url, room_url=args.room_url,
token, token=args.token,
bot_name="Bot", bot_name="Bot",
params=DailyParams( params=DailyParams(
audio_in_enabled=True, audio_in_enabled=True,
@@ -217,7 +217,7 @@ async def local_daily():
) )
test_config = { test_config = {
"personality": "witty", "personality": args.personality,
} }
await main(transport, test_config) await main(transport, test_config)
@@ -227,7 +227,24 @@ async def local_daily():
# Local development entry point # Local development entry point
if LOCAL_RUN and __name__ == "__main__": if LOCAL_RUN and __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Run the Word Wrangler bot in local development mode"
)
parser.add_argument(
"-u", "--room-url", type=str, default=os.getenv("DAILY_SAMPLE_ROOM_URL", "")
)
parser.add_argument(
"-t", "--token", type=str, default=os.getenv("DAILY_SAMPLE_ROOM_TOKEN", None)
)
parser.add_argument(
"-p",
"--personality",
default="witty",
choices=["friendly", "professional", "enthusiastic", "thoughtful", "witty"],
help="Personality preset for the bot (friendly, professional, enthusiastic, thoughtful, witty)",
)
args = parser.parse_args()
try: try:
asyncio.run(local_daily()) asyncio.run(local_daily(args))
except Exception as e: except Exception as e:
logger.exception(f"Failed to run in local mode: {e}") logger.exception(f"Failed to run in local mode: {e}")

View File

@@ -160,14 +160,15 @@ async def rtvi_connect(request: Request) -> Dict[Any, Any]:
Raises: Raises:
HTTPException: If room creation, token generation, or bot startup fails HTTPException: If room creation, token generation, or bot startup fails
""" """
print("Creating room for RTVI connection") body = await request.json()
print("Creating room for RTVI connection", body)
room_url, token = await create_room_and_token() room_url, token = await create_room_and_token()
print(f"Room URL: {room_url}") print(f"Room URL: {room_url}")
# Start the bot process # Start the bot process
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
[f"python3 -m bot -u {room_url} -t {token}"], [f"python3 -m bot -u {room_url} -t {token} -p {body.get('personality', 'witty')}"],
shell=True, shell=True,
bufsize=1, bufsize=1,
cwd=os.path.dirname(os.path.abspath(__file__)), cwd=os.path.dirname(os.path.abspath(__file__)),

View File

@@ -25,14 +25,14 @@ dependencies = [
"docstring_parser~=0.16", "docstring_parser~=0.16",
"loguru~=0.7.3", "loguru~=0.7.3",
"Markdown~=3.7", "Markdown~=3.7",
"numpy~=1.26.4", "numpy>=1.26.4",
"Pillow~=11.1.0", "Pillow~=11.1.0",
"protobuf~=5.29.3", "protobuf~=5.29.3",
"pydantic~=2.10.6", "pydantic~=2.10.6",
"pyloudnorm~=0.1.1", "pyloudnorm~=0.1.1",
"resampy~=0.4.3", "resampy~=0.4.3",
"soxr~=0.5.0", "soxr~=0.5.0",
"openai~=1.70.0", "openai~=1.74.0",
] ]
[project.urls] [project.urls]
@@ -42,7 +42,7 @@ Website = "https://pipecat.ai"
[project.optional-dependencies] [project.optional-dependencies]
anthropic = [ "anthropic~=0.49.0" ] anthropic = [ "anthropic~=0.49.0" ]
assemblyai = [ "websockets~=13.1" ] assemblyai = [ "websockets~=13.1" ]
aws = [ "boto3~=1.37.16", "websockets~=13.1" ] aws = [ "aioboto3~=15.0.0", "websockets~=13.1" ]
aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.0.2" ] aws-nova-sonic = [ "aws_sdk_bedrock_runtime~=0.0.2" ]
azure = [ "azure-cognitiveservices-speech~=1.42.0"] azure = [ "azure-cognitiveservices-speech~=1.42.0"]
cartesia = [ "cartesia~=2.0.3", "websockets~=13.1" ] cartesia = [ "cartesia~=2.0.3", "websockets~=13.1" ]
@@ -68,7 +68,7 @@ local = [ "pyaudio~=0.2.14" ]
mcp = [ "mcp[cli]~=1.9.4" ] mcp = [ "mcp[cli]~=1.9.4" ]
mem0 = [ "mem0ai~=0.1.94" ] mem0 = [ "mem0ai~=0.1.94" ]
mlx-whisper = [ "mlx-whisper~=0.4.2" ] mlx-whisper = [ "mlx-whisper~=0.4.2" ]
moondream = [ "einops~=0.8.0", "timm~=1.0.13", "transformers~=4.48.0" ] moondream = [ "einops~=0.8.0", "timm~=1.0.13", "transformers>=4.48.0" ]
nim = [] nim = []
neuphonic = [ "pyneuphonic~=1.5.13", "websockets~=13.1" ] neuphonic = [ "pyneuphonic~=1.5.13", "websockets~=13.1" ]
noisereduce = [ "noisereduce~=3.0.3" ] noisereduce = [ "noisereduce~=3.0.3" ]
@@ -79,19 +79,20 @@ perplexity = []
playht = [ "pyht~=0.1.12", "websockets~=13.1" ] playht = [ "pyht~=0.1.12", "websockets~=13.1" ]
qwen = [] qwen = []
rime = [ "websockets~=13.1" ] rime = [ "websockets~=13.1" ]
riva = [ "nvidia-riva-client~=2.19.1" ] riva = [ "nvidia-riva-client~=2.21.1" ]
sambanova = [] sambanova = []
sentry = [ "sentry-sdk~=2.23.1" ] sentry = [ "sentry-sdk~=2.23.1" ]
local-smart-turn = [ "coremltools>=8.0", "transformers", "torch==2.5.0", "torchaudio==2.5.0" ] local-smart-turn = [ "coremltools>=8.0", "transformers", "torch==2.5.0", "torchaudio==2.5.0" ]
remote-smart-turn = [] remote-smart-turn = []
silero = [ "onnxruntime~=1.20.1" ] silero = [ "onnxruntime~=1.20.1" ]
simli = [ "simli-ai~=0.1.10"] simli = [ "simli-ai~=0.1.10"]
soniox = [ "websockets~=13.1" ]
soundfile = [ "soundfile~=0.13.0" ] soundfile = [ "soundfile~=0.13.0" ]
speechmatics = [ "speechmatics-rt>=0.3.1" ] speechmatics = [ "speechmatics-rt>=0.3.1" ]
tavus=[] tavus=[]
together = [] together = []
tracing = [ "opentelemetry-sdk>=1.33.0", "opentelemetry-api>=1.33.0", "opentelemetry-instrumentation>=0.54b0" ] tracing = [ "opentelemetry-sdk>=1.33.0", "opentelemetry-api>=1.33.0", "opentelemetry-instrumentation>=0.54b0" ]
ultravox = [ "transformers~=4.48.0", "vllm~=0.7.3" ] ultravox = [ "transformers>=4.48.0", "vllm~=0.7.3" ]
webrtc = [ "aiortc~=1.11.0", "opencv-python~=4.11.0.86" ] webrtc = [ "aiortc~=1.11.0", "opencv-python~=4.11.0.86" ]
websocket = [ "websockets~=13.1", "fastapi~=0.115.6" ] websocket = [ "websockets~=13.1", "fastapi~=0.115.6" ]
whisper = [ "faster-whisper~=1.1.1" ] whisper = [ "faster-whisper~=1.1.1" ]

View File

@@ -1,5 +1,10 @@
ruff format src
ruff format examples #!/bin/bash
ruff format tests
ruff format scripts SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ruff check --select I,D --fix PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
echo "Running ruff format..."
ruff format "$PROJECT_ROOT"
echo "Running ruff check..."
ruff check --fix "$PROJECT_ROOT"

View File

@@ -76,6 +76,16 @@ class BaseTurnAnalyzer(ABC):
""" """
pass pass
@property
@abstractmethod
def params(self):
"""Get the current turn analyzer parameters.
Returns:
Current turn analyzer configuration parameters.
"""
pass
@abstractmethod @abstractmethod
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState: def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
"""Appends audio data for analysis. """Appends audio data for analysis.

View File

@@ -87,6 +87,15 @@ class BaseSmartTurn(BaseTurnAnalyzer):
""" """
return self._speech_triggered return self._speech_triggered
@property
def params(self) -> SmartTurnParams:
"""Get the current smart turn parameters.
Returns:
Current smart turn configuration parameters.
"""
return self._params
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState: def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
"""Append audio data for turn analysis. """Append audio data for turn analysis.

View File

@@ -0,0 +1,192 @@
#
# Copyright (c) 2025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Local PyTorch turn analyzer for on-device ML inference using the smart-turn-v2 model.
This module provides a smart turn analyzer that uses PyTorch models for
local end-of-turn detection without requiring network connectivity.
"""
from typing import Any, Dict
import numpy as np
from loguru import logger
from pipecat.audio.turn.smart_turn.base_smart_turn import BaseSmartTurn
try:
import torch
import torch.nn.functional as F
from torch import nn
from transformers import (
Wav2Vec2Config,
Wav2Vec2Model,
Wav2Vec2PreTrainedModel,
Wav2Vec2Processor,
)
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error(
"In order to use LocalSmartTurnAnalyzerV2, you need to `pip install pipecat-ai[local-smart-turn]`."
)
raise Exception(f"Missing module: {e}")
class LocalSmartTurnAnalyzerV2(BaseSmartTurn):
"""Local turn analyzer using the smart-turn-v2 PyTorch model.
Provides end-of-turn detection using locally-stored PyTorch models,
enabling offline operation without network dependencies. Uses
Wav2Vec2 architecture for audio sequence classification.
"""
def __init__(self, *, smart_turn_model_path: str, **kwargs):
"""Initialize the local PyTorch smart-turn-v2 analyzer.
Args:
smart_turn_model_path: Path to directory containing the PyTorch model
and feature extractor files. If empty, uses default HuggingFace model.
**kwargs: Additional arguments passed to BaseSmartTurn.
"""
super().__init__(**kwargs)
if not smart_turn_model_path:
# Define the path to the pretrained model on Hugging Face
smart_turn_model_path = "pipecat-ai/smart-turn-v2"
logger.debug("Loading Local Smart Turn v2 model...")
# Load the pretrained model for sequence classification
self._turn_model = _Wav2Vec2ForEndpointing.from_pretrained(smart_turn_model_path)
# Load the corresponding feature extractor for preprocessing audio
self._turn_processor = Wav2Vec2Processor.from_pretrained(smart_turn_model_path)
# Set device to GPU if available, else CPU
self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Move model to selected device and set it to evaluation mode
self._turn_model = self._turn_model.to(self._device)
self._turn_model.eval()
logger.debug("Loaded Local Smart Turn v2")
async def _predict_endpoint(self, audio_array: np.ndarray) -> Dict[str, Any]:
"""Predict end-of-turn using local PyTorch model."""
inputs = self._turn_processor(
audio_array,
sampling_rate=16000,
padding="max_length",
truncation=True,
max_length=16000 * 16, # 16 seconds at 16kHz
return_attention_mask=True,
return_tensors="pt",
)
# Move inputs to device
inputs = {k: v.to(self._device) for k, v in inputs.items()}
# Run inference
with torch.no_grad():
outputs = self._turn_model(**inputs)
# The model returns sigmoid probabilities directly in the logits field
probability = outputs["logits"][0].item()
# Make prediction (1 for Complete, 0 for Incomplete)
prediction = 1 if probability > 0.5 else 0
return {
"prediction": prediction,
"probability": probability,
}
class _Wav2Vec2ForEndpointing(Wav2Vec2PreTrainedModel):
def __init__(self, config: Wav2Vec2Config):
super().__init__(config)
self.wav2vec2 = Wav2Vec2Model(config)
self.pool_attention = nn.Sequential(
nn.Linear(config.hidden_size, 256), nn.Tanh(), nn.Linear(256, 1)
)
self.classifier = nn.Sequential(
nn.Linear(config.hidden_size, 256),
nn.LayerNorm(256),
nn.GELU(),
nn.Dropout(0.1),
nn.Linear(256, 64),
nn.GELU(),
nn.Linear(64, 1),
)
for module in self.classifier:
if isinstance(module, nn.Linear):
module.weight.data.normal_(mean=0.0, std=0.1)
if module.bias is not None:
module.bias.data.zero_()
for module in self.pool_attention:
if isinstance(module, nn.Linear):
module.weight.data.normal_(mean=0.0, std=0.1)
if module.bias is not None:
module.bias.data.zero_()
def attention_pool(self, hidden_states, attention_mask):
# Calculate attention weights
attention_weights = self.pool_attention(hidden_states)
if attention_mask is None:
raise ValueError("attention_mask must be provided for attention pooling")
attention_weights = attention_weights + (
(1.0 - attention_mask.unsqueeze(-1).to(attention_weights.dtype)) * -1e9
)
attention_weights = F.softmax(attention_weights, dim=1)
# Apply attention to hidden states
weighted_sum = torch.sum(hidden_states * attention_weights, dim=1)
return weighted_sum
def forward(self, input_values, attention_mask=None, labels=None):
outputs = self.wav2vec2(input_values, attention_mask=attention_mask)
hidden_states = outputs[0]
# Create transformer padding mask
if attention_mask is not None:
input_length = attention_mask.size(1)
hidden_length = hidden_states.size(1)
ratio = input_length / hidden_length
indices = (torch.arange(hidden_length, device=attention_mask.device) * ratio).long()
attention_mask = attention_mask[:, indices]
attention_mask = attention_mask.bool()
else:
attention_mask = None
pooled = self.attention_pool(hidden_states, attention_mask)
logits = self.classifier(pooled)
if torch.isnan(logits).any():
raise ValueError("NaN values detected in logits")
if labels is not None:
# Calculate positive sample weight based on batch statistics
pos_weight = ((labels == 0).sum() / (labels == 1).sum()).clamp(min=0.1, max=10.0)
loss_fct = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
labels = labels.float()
loss = loss_fct(logits.view(-1), labels.view(-1))
# Add L2 regularization for classifier layers
l2_lambda = 0.01
l2_reg = torch.tensor(0.0, device=logits.device)
for param in self.classifier.parameters():
l2_reg += torch.norm(param)
loss += l2_lambda * l2_reg
probs = torch.sigmoid(logits.detach())
return {"loss": loss, "logits": probs}
probs = torch.sigmoid(logits)
return {"logits": probs}

View File

@@ -183,36 +183,37 @@ class VADAnalyzer(ABC):
if len(self._vad_buffer) < num_required_bytes: if len(self._vad_buffer) < num_required_bytes:
return self._vad_state return self._vad_state
audio_frames = self._vad_buffer[:num_required_bytes] while len(self._vad_buffer) >= num_required_bytes:
self._vad_buffer = self._vad_buffer[num_required_bytes:] audio_frames = self._vad_buffer[:num_required_bytes]
self._vad_buffer = self._vad_buffer[num_required_bytes:]
confidence = self.voice_confidence(audio_frames) confidence = self.voice_confidence(audio_frames)
volume = self._get_smoothed_volume(audio_frames) volume = self._get_smoothed_volume(audio_frames)
self._prev_volume = volume self._prev_volume = volume
speaking = confidence >= self._params.confidence and volume >= self._params.min_volume speaking = confidence >= self._params.confidence and volume >= self._params.min_volume
if speaking: if speaking:
match self._vad_state: match self._vad_state:
case VADState.QUIET: case VADState.QUIET:
self._vad_state = VADState.STARTING self._vad_state = VADState.STARTING
self._vad_starting_count = 1 self._vad_starting_count = 1
case VADState.STARTING: case VADState.STARTING:
self._vad_starting_count += 1 self._vad_starting_count += 1
case VADState.STOPPING: case VADState.STOPPING:
self._vad_state = VADState.SPEAKING self._vad_state = VADState.SPEAKING
self._vad_stopping_count = 0 self._vad_stopping_count = 0
else: else:
match self._vad_state: match self._vad_state:
case VADState.STARTING: case VADState.STARTING:
self._vad_state = VADState.QUIET self._vad_state = VADState.QUIET
self._vad_starting_count = 0 self._vad_starting_count = 0
case VADState.SPEAKING: case VADState.SPEAKING:
self._vad_state = VADState.STOPPING self._vad_state = VADState.STOPPING
self._vad_stopping_count = 1 self._vad_stopping_count = 1
case VADState.STOPPING: case VADState.STOPPING:
self._vad_stopping_count += 1 self._vad_stopping_count += 1
if ( if (
self._vad_state == VADState.STARTING self._vad_state == VADState.STARTING

View File

@@ -9,6 +9,21 @@
This module provides a unified interface for running Pipecat examples across This module provides a unified interface for running Pipecat examples across
different transport types including Daily.co, WebRTC, and Twilio. It handles different transport types including Daily.co, WebRTC, and Twilio. It handles
setup, configuration, and lifecycle management for each transport type. setup, configuration, and lifecycle management for each transport type.
Example usage:
SmallWebRTCTransport::
python bot.py --transport webrtc
DailyTransport::
python bot.py --transport daily
Twilio::
python bot.py --transport twilio --proxy username.ngrok.io
# Note: Concurrently, run an ngrok tunnel to your local server:
# ngrok http 7860
""" """
import argparse import argparse

View File

@@ -28,6 +28,7 @@ from typing import (
) )
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.metrics.metrics import MetricsData from pipecat.metrics.metrics import MetricsData
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
@@ -1145,6 +1146,23 @@ class OutputDTMFUrgentFrame(DTMFFrame, SystemFrame):
pass pass
@dataclass
class SpeechControlParamsFrame(SystemFrame):
"""Frame for notifying processors of speech control parameter changes.
This includes parameters for both VAD (Voice Activity Detection) and
turn-taking analysis. It allows downstream processors to adjust their
behavior based on updated interaction control settings.
Parameters:
vad_params: Current VAD parameters.
turn_params: Current turn-taking analysis parameters.
"""
vad_params: Optional[VADParams] = None
turn_params: Optional[SmartTurnParams] = None
# #
# Control frames # Control frames
# #

View File

@@ -273,12 +273,17 @@ class ParallelPipeline(BasePipeline):
if not self._down_task: if not self._down_task:
self._down_task = self.create_task(self._process_down_queue()) self._down_task = self.create_task(self._process_down_queue())
async def _drain_queue(self, queue: asyncio.Queue):
try:
while not queue.empty():
queue.get_nowait()
except asyncio.QueueEmpty:
logger.debug(f"Draining {self} queue already empty")
async def _drain_queues(self): async def _drain_queues(self):
"""Drain all frames from upstream and downstream queues.""" """Drain all frames from upstream and downstream queues."""
while not self._up_queue.empty: await self._drain_queue(self._up_queue)
await self._up_queue.get() await self._drain_queue(self._down_queue)
while not self._down_queue.empty:
await self._down_queue.get()
async def _handle_interruption(self): async def _handle_interruption(self):
"""Handle interruption by cancelling tasks, draining queues, and restarting.""" """Handle interruption by cancelling tasks, draining queues, and restarting."""

View File

@@ -19,6 +19,8 @@ from typing import Dict, List, Literal, Optional, Set
from loguru import logger from loguru import logger
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotInterruptionFrame, BotInterruptionFrame,
BotStartedSpeakingFrame, BotStartedSpeakingFrame,
@@ -43,6 +45,7 @@ from pipecat.frames.frames import (
LLMSetToolsFrame, LLMSetToolsFrame,
LLMTextFrame, LLMTextFrame,
OpenAILLMContextAssistantTimestampFrame, OpenAILLMContextAssistantTimestampFrame,
SpeechControlParamsFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TextFrame, TextFrame,
@@ -67,9 +70,13 @@ class LLMUserAggregatorParams:
aggregation_timeout: Maximum time in seconds to wait for additional aggregation_timeout: Maximum time in seconds to wait for additional
transcription content before pushing aggregated result. This transcription content before pushing aggregated result. This
timeout is used only when the transcription is slow to arrive. timeout is used only when the transcription is slow to arrive.
turn_emulated_vad_timeout: Maximum time in seconds to wait for emulated
VAD when using turn-based analysis. Applied when transcription is
received but VAD didn't detect speech (e.g., whispered utterances).
""" """
aggregation_timeout: float = 0.5 aggregation_timeout: float = 0.5
turn_emulated_vad_timeout: float = 0.8
@dataclass @dataclass
@@ -390,6 +397,9 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
""" """
super().__init__(context=context, role="user", **kwargs) super().__init__(context=context, role="user", **kwargs)
self._params = params or LLMUserAggregatorParams() self._params = params or LLMUserAggregatorParams()
self._vad_params: Optional[VADParams] = None
self._turn_params: Optional[SmartTurnParams] = None
if "aggregation_timeout" in kwargs: if "aggregation_timeout" in kwargs:
import warnings import warnings
@@ -477,6 +487,10 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
self.set_tools(frame.tools) self.set_tools(frame.tools)
elif isinstance(frame, LLMSetToolChoiceFrame): elif isinstance(frame, LLMSetToolChoiceFrame):
self.set_tool_choice(frame.tool_choice) self.set_tool_choice(frame.tool_choice)
elif isinstance(frame, SpeechControlParamsFrame):
self._vad_params = frame.vad_params
self._turn_params = frame.turn_params
await self.push_frame(frame, direction)
else: else:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -618,9 +632,40 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
async def _aggregation_task_handler(self): async def _aggregation_task_handler(self):
while True: while True:
try: try:
await asyncio.wait_for( # The _aggregation_task_handler handles two distinct timeout scenarios:
self._aggregation_event.wait(), self._params.aggregation_timeout #
) # 1. When emulating_vad=True: Wait for emulated VAD timeout before
# pushing aggregation (simulating VAD behavior when no actual VAD
# detection occurred).
#
# 2. When emulating_vad=False: Use aggregation_timeout as a buffer
# to wait for potential late-arriving transcription frames after
# a real VAD event.
#
# For emulated VAD scenarios, the timeout strategy depends on whether
# a turn analyzer is configured:
#
# - WITH turn analyzer: Use turn_emulated_vad_timeout parameter because
# the VAD's stop_secs is set very low (e.g. 0.2s) for rapid speech
# chunking to feed the turn analyzer. This low value is too fast
# for emulated VAD scenarios where we need to allow users time to
# finish speaking (e.g. 0.8s).
#
# - WITHOUT turn analyzer: Use VAD's stop_secs directly to maintain
# consistent user experience between real VAD detection and
# emulated VAD scenarios.
if not self._emulating_vad:
timeout = self._params.aggregation_timeout
elif self._turn_params:
timeout = self._params.turn_emulated_vad_timeout
else:
# Use VAD stop_secs when no turn analyzer is present, fallback if no VAD params
timeout = (
self._vad_params.stop_secs
if self._vad_params
else self._params.turn_emulated_vad_timeout
)
await asyncio.wait_for(self._aggregation_event.wait(), timeout)
await self._maybe_emulate_user_speaking() await self._maybe_emulate_user_speaking()
except asyncio.TimeoutError: except asyncio.TimeoutError:
if not self._user_speaking: if not self._user_speaking:
@@ -648,7 +693,11 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
# to emulate VAD (i.e. user start/stopped speaking), but we do it only # to emulate VAD (i.e. user start/stopped speaking), but we do it only
# if the bot is not speaking. If the bot is speaking and we really have # if the bot is not speaking. If the bot is speaking and we really have
# a short utterance we don't really want to interrupt the bot. # a short utterance we don't really want to interrupt the bot.
if not self._user_speaking and not self._waiting_for_aggregation: if (
not self._user_speaking
and not self._waiting_for_aggregation
and len(self._aggregation) > 0
):
if self._bot_speaking: if self._bot_speaking:
# If we reached this case and the bot is speaking, let's ignore # If we reached this case and the bot is speaking, let's ignore
# what the user said. # what the user said.

View File

@@ -44,6 +44,7 @@ from pipecat.frames.frames import (
InterimTranscriptionFrame, InterimTranscriptionFrame,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
LLMMessagesAppendFrame,
LLMTextFrame, LLMTextFrame,
MetricsFrame, MetricsFrame,
StartFrame, StartFrame,
@@ -71,13 +72,14 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import ( from pipecat.services.llm_service import (
FunctionCallParams, # TODO(aleix): we shouldn't import `services` from `processors` FunctionCallParams, # TODO(aleix): we shouldn't import `services` from `processors`
) )
from pipecat.services.openai.llm import OpenAIContextAggregatorPair
from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport from pipecat.transports.base_transport import BaseTransport
from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue from pipecat.utils.asyncio.watchdog_queue import WatchdogQueue
from pipecat.utils.string import match_endofsentence from pipecat.utils.string import match_endofsentence
RTVI_PROTOCOL_VERSION = "0.3.0" RTVI_PROTOCOL_VERSION = "1.0.0"
RTVI_MESSAGE_LABEL = "rtvi-ai" RTVI_MESSAGE_LABEL = "rtvi-ai"
RTVIMessageLiteral = Literal["rtvi-ai"] RTVIMessageLiteral = Literal["rtvi-ai"]
@@ -90,6 +92,10 @@ class RTVIServiceOption(BaseModel):
Defines a configurable option that can be set for an RTVI service, Defines a configurable option that can be set for an RTVI service,
including its name, type, and handler function. including its name, type, and handler function.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -104,6 +110,10 @@ class RTVIService(BaseModel):
Represents a service that can be configured and used within the RTVI protocol, Represents a service that can be configured and used within the RTVI protocol,
containing a name and list of configurable options. containing a name and list of configurable options.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -122,6 +132,10 @@ class RTVIActionArgumentData(BaseModel):
"""Data for an RTVI action argument. """Data for an RTVI action argument.
Contains the name and value of an argument passed to an RTVI action. Contains the name and value of an argument passed to an RTVI action.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -132,6 +146,10 @@ class RTVIActionArgument(BaseModel):
"""Definition of an RTVI action argument. """Definition of an RTVI action argument.
Specifies the name and expected type of an argument for an RTVI action. Specifies the name and expected type of an argument for an RTVI action.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -143,6 +161,10 @@ class RTVIAction(BaseModel):
Represents an action that can be executed within the RTVI protocol, Represents an action that can be executed within the RTVI protocol,
including its service, name, arguments, and handler function. including its service, name, arguments, and handler function.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
service: str service: str
@@ -166,6 +188,10 @@ class RTVIServiceOptionConfig(BaseModel):
"""Configuration value for an RTVI service option. """Configuration value for an RTVI service option.
Contains the name and value to set for a specific service option. Contains the name and value to set for a specific service option.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -176,6 +202,10 @@ class RTVIServiceConfig(BaseModel):
"""Configuration for an RTVI service. """Configuration for an RTVI service.
Contains the service name and list of option configurations to apply. Contains the service name and list of option configurations to apply.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
service: str service: str
@@ -186,6 +216,10 @@ class RTVIConfig(BaseModel):
"""Complete RTVI configuration. """Complete RTVI configuration.
Contains the full configuration for all RTVI services. Contains the full configuration for all RTVI services.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
config: List[RTVIServiceConfig] config: List[RTVIServiceConfig]
@@ -196,10 +230,15 @@ class RTVIConfig(BaseModel):
# #
# deprecated
class RTVIUpdateConfig(BaseModel): class RTVIUpdateConfig(BaseModel):
"""Request to update RTVI configuration. """Request to update RTVI configuration.
Contains new configuration settings and whether to interrupt the bot. Contains new configuration settings and whether to interrupt the bot.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
config: List[RTVIServiceConfig] config: List[RTVIServiceConfig]
@@ -210,6 +249,10 @@ class RTVIActionRunArgument(BaseModel):
"""Argument for running an RTVI action. """Argument for running an RTVI action.
Contains the name and value of an argument to pass to an action. Contains the name and value of an argument to pass to an action.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
name: str name: str
@@ -220,6 +263,10 @@ class RTVIActionRun(BaseModel):
"""Request to run an RTVI action. """Request to run an RTVI action.
Contains the service, action name, and optional arguments. Contains the service, action name, and optional arguments.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
service: str service: str
@@ -234,12 +281,80 @@ class RTVIActionFrame(DataFrame):
Parameters: Parameters:
rtvi_action_run: The action to execute. rtvi_action_run: The action to execute.
message_id: Optional message ID for response correlation. message_id: Optional message ID for response correlation.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
rtvi_action_run: RTVIActionRun rtvi_action_run: RTVIActionRun
message_id: Optional[str] = None message_id: Optional[str] = None
class RTVIRawClientMessageData(BaseModel):
"""Data structure expected from client messages sent to the RTVI server."""
t: str
d: Optional[Any] = None
class RTVIClientMessage(BaseModel):
"""Cleansed data structure for client messages for handling."""
msg_id: str
type: str
data: Optional[Any] = None
@dataclass
class RTVIClientMessageFrame(SystemFrame):
"""A frame for sending messages from the client to the RTVI server.
This frame is meant for custom messaging from the client to the server
and expects a server-response message.
"""
msg_id: str
type: str
data: Optional[Any] = None
@dataclass
class RTVIServerResponseFrame(SystemFrame):
"""A frame for responding to a client RTVI message.
This frame should be sent in response to an RTVIClientMessageFrame
and include the original RTVIClientMessageFrame to ensure the response
is properly attributed to the original request. To respond with an error,
set the `error` field to a string describing the error. This will result
in the client receiving a `response-error` message instead of a
`server-response` message.
"""
client_msg: RTVIClientMessageFrame
data: Optional[Any] = None
error: Optional[str] = None
class RTVIRawServerResponseData(BaseModel):
"""Data structure for server responses to client messages."""
t: str
d: Optional[Any] = None
class RTVIServerResponse(BaseModel):
"""The RTVI-formatted message response from the server to the client.
This message is used to respond to custom messages sent by the client.
"""
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
type: Literal["server-response"] = "server-response"
id: str
data: RTVIRawServerResponseData
class RTVIMessage(BaseModel): class RTVIMessage(BaseModel):
"""Base RTVI message structure. """Base RTVI message structure.
@@ -269,7 +384,7 @@ class RTVIErrorResponseData(BaseModel):
class RTVIErrorResponse(BaseModel): class RTVIErrorResponse(BaseModel):
"""RTVI error response message. """RTVI error response message.
Sent in response to a client request that resulted in an error. RTVI Formatted error response message for relaying failed client requests.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -285,13 +400,13 @@ class RTVIErrorData(BaseModel):
""" """
error: str error: str
fatal: bool fatal: bool # Indicates the pipeline has stopped due to this error
class RTVIError(BaseModel): class RTVIError(BaseModel):
"""RTVI error event message. """RTVI error event message.
Sent when an error occurs that isn't in response to a specific request. RTVI Formatted error message for relaying errors in the pipeline.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -303,6 +418,10 @@ class RTVIDescribeConfigData(BaseModel):
"""Data for describing available RTVI configuration. """Data for describing available RTVI configuration.
Contains the list of available services and their options. Contains the list of available services and their options.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
config: List[RTVIService] config: List[RTVIService]
@@ -312,6 +431,10 @@ class RTVIDescribeConfig(BaseModel):
"""Message describing available RTVI configuration. """Message describing available RTVI configuration.
Sent in response to a describe-config request. Sent in response to a describe-config request.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -324,6 +447,10 @@ class RTVIDescribeActionsData(BaseModel):
"""Data for describing available RTVI actions. """Data for describing available RTVI actions.
Contains the list of available actions that can be executed. Contains the list of available actions that can be executed.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
actions: List[RTVIAction] actions: List[RTVIAction]
@@ -333,6 +460,10 @@ class RTVIDescribeActions(BaseModel):
"""Message describing available RTVI actions. """Message describing available RTVI actions.
Sent in response to a describe-actions request. Sent in response to a describe-actions request.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -345,6 +476,10 @@ class RTVIConfigResponse(BaseModel):
"""Response containing current RTVI configuration. """Response containing current RTVI configuration.
Sent in response to a get-config request. Sent in response to a get-config request.
.. deprecated:: 0.0.75
Pipeline Configuration has been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -357,6 +492,10 @@ class RTVIActionResponseData(BaseModel):
"""Data for an RTVI action response. """Data for an RTVI action response.
Contains the result of executing an action. Contains the result of executing an action.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
result: ActionResult result: ActionResult
@@ -366,6 +505,10 @@ class RTVIActionResponse(BaseModel):
"""Response to an RTVI action execution. """Response to an RTVI action execution.
Sent after successfully executing an action. Sent after successfully executing an action.
.. deprecated:: 0.0.75
Actions have been removed as part of the RTVI protocol 1.0.0.
Use custom client and server messages instead.
""" """
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
@@ -374,6 +517,30 @@ class RTVIActionResponse(BaseModel):
data: RTVIActionResponseData data: RTVIActionResponseData
class AboutClientData(BaseModel):
"""Data about the RTVI client.
Contains information about the client, including which RTVI library it
is using, what platform it is on and any additional details, if available.
"""
library: str
library_version: Optional[str] = None
platform: Optional[str] = None
platform_version: Optional[str] = None
platform_details: Optional[Any] = None
class RTVIClientReadyData(BaseModel):
"""Data format of client ready messages.
Contains the RTVIprotocol version and client information.
"""
version: str
about: AboutClientData
class RTVIBotReadyData(BaseModel): class RTVIBotReadyData(BaseModel):
"""Data for bot ready notification. """Data for bot ready notification.
@@ -381,7 +548,10 @@ class RTVIBotReadyData(BaseModel):
""" """
version: str version: str
config: List[RTVIServiceConfig] # The config field is deprecated and will not be included if
# the client's rtvi version is 1.0.0 or higher.
config: Optional[List[RTVIServiceConfig]] = None
about: Optional[Mapping[str, Any]] = None
class RTVIBotReady(BaseModel): class RTVIBotReady(BaseModel):
@@ -418,6 +588,25 @@ class RTVILLMFunctionCallMessage(BaseModel):
data: RTVILLMFunctionCallMessageData data: RTVILLMFunctionCallMessageData
class RTVIAppendToContextData(BaseModel):
"""Data format for appending messages to the context.
Contains the role, content, and whether to run the message immediately.
"""
role: Literal["user", "assistant"] | str
content: Any
run_immediately: bool = False
class RTVIAppendToContext(BaseModel):
"""RTVI Message format to append content to the LLM context."""
label: RTVIMessageLiteral = RTVI_MESSAGE_LABEL
type: Literal["append-to-context"] = "append-to-context"
data: RTVIAppendToContextData
class RTVILLMFunctionCallStartMessageData(BaseModel): class RTVILLMFunctionCallStartMessageData(BaseModel):
"""Data for LLM function call start notification. """Data for LLM function call start notification.
@@ -752,6 +941,11 @@ class RTVIObserver(BaseObserver):
elif isinstance(frame, RTVIServerMessageFrame): elif isinstance(frame, RTVIServerMessageFrame):
message = RTVIServerMessage(data=frame.data) message = RTVIServerMessage(data=frame.data)
await self.push_transport_message_urgent(message) await self.push_transport_message_urgent(message)
elif isinstance(frame, RTVIServerResponseFrame):
if frame.error is not None:
await self._send_error_response(frame)
else:
await self._send_server_response(frame)
if mark_as_seen: if mark_as_seen:
self._frames_seen.add(frame.id) self._frames_seen.add(frame.id)
@@ -879,6 +1073,22 @@ class RTVIObserver(BaseObserver):
message = RTVIMetricsMessage(data=metrics) message = RTVIMetricsMessage(data=metrics)
await self.push_transport_message_urgent(message) await self.push_transport_message_urgent(message)
async def _send_server_response(self, frame: RTVIServerResponseFrame):
"""Send a response to the client for a specific request."""
message = RTVIServerResponse(
id=str(frame.client_msg.msg_id),
data=RTVIRawServerResponseData(t=frame.client_msg.type, d=frame.data),
)
await self.push_transport_message_urgent(message)
async def _send_error_response(self, frame: RTVIServerResponseFrame):
"""Send a response to the client for a specific request."""
if self._params.errors_enabled:
message = RTVIErrorResponse(
id=str(frame.client_msg.msg_id), data=RTVIErrorResponseData(error=frame.error)
)
await self.push_transport_message_urgent(message)
class RTVIProcessor(FrameProcessor): class RTVIProcessor(FrameProcessor):
"""Main processor for handling RTVI protocol messages and actions. """Main processor for handling RTVI protocol messages and actions.
@@ -908,6 +1118,7 @@ class RTVIProcessor(FrameProcessor):
self._bot_ready = False self._bot_ready = False
self._client_ready = False self._client_ready = False
self._client_ready_id = "" self._client_ready_id = ""
self._client_version = []
self._errors_enabled = True self._errors_enabled = True
self._registered_actions: Dict[str, RTVIAction] = {} self._registered_actions: Dict[str, RTVIAction] = {}
@@ -921,6 +1132,7 @@ class RTVIProcessor(FrameProcessor):
self._register_event_handler("on_bot_started") self._register_event_handler("on_bot_started")
self._register_event_handler("on_client_ready") self._register_event_handler("on_client_ready")
self._register_event_handler("on_client_message")
self._input_transport = None self._input_transport = None
self._transport = transport self._transport = transport
@@ -936,6 +1148,15 @@ class RTVIProcessor(FrameProcessor):
Args: Args:
action: The action to register. action: The action to register.
""" """
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"The actions API is deprecated, use server and client messages instead.",
DeprecationWarning,
)
id = self._action_id(action.service, action.action) id = self._action_id(action.service, action.action)
self._registered_actions[id] = action self._registered_actions[id] = action
@@ -945,6 +1166,15 @@ class RTVIProcessor(FrameProcessor):
Args: Args:
service: The service to register. service: The service to register.
""" """
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"The actions API is deprecated, use server and client messages instead.",
DeprecationWarning,
)
self._registered_services[service.name] = service self._registered_services[service.name] = service
async def set_client_ready(self): async def set_client_ready(self):
@@ -970,6 +1200,22 @@ class RTVIProcessor(FrameProcessor):
"""Send a bot interruption frame upstream.""" """Send a bot interruption frame upstream."""
await self.push_frame(BotInterruptionFrame(), FrameDirection.UPSTREAM) await self.push_frame(BotInterruptionFrame(), FrameDirection.UPSTREAM)
async def send_server_message(self, data: Any):
"""Send a server message to the client."""
message = RTVIServerMessage(data=data)
await self._send_server_message(message)
async def send_server_response(self, client_msg: RTVIClientMessage, data: Any):
"""Send a server response for a given client message."""
message = RTVIServerResponse(
id=client_msg.msg_id, data=RTVIRawServerResponseData(t=client_msg.type, d=data)
)
await self._send_server_message(message)
async def send_error_response(self, client_msg: RTVIClientMessage, error: str):
"""Send an error response for a given client message."""
await self._send_error_response(id=client_msg.msg_id, error=error)
async def send_error(self, error: str): async def send_error(self, error: str):
"""Send an error message to the client. """Send an error message to the client.
@@ -1013,9 +1259,6 @@ class RTVIProcessor(FrameProcessor):
function_name: Name of the function being called. function_name: Name of the function being called.
llm: The LLM processor making the call. llm: The LLM processor making the call.
context: The LLM context. context: The LLM context.
Note:
This method is deprecated. Use handle_function_call() instead.
""" """
import warnings import warnings
@@ -1136,7 +1379,15 @@ class RTVIProcessor(FrameProcessor):
try: try:
match message.type: match message.type:
case "client-ready": case "client-ready":
await self._handle_client_ready(message.id) data = None
try:
data = RTVIClientReadyData.model_validate(message.data)
except ValidationError:
# Not all clients have been updated to RTVI 1.0.0.
# For now, that's okay, we just log their info as unknown.
data = None
pass
await self._handle_client_ready(message.id, data)
case "describe-actions": case "describe-actions":
await self._handle_describe_actions(message.id) await self._handle_describe_actions(message.id)
case "describe-config": case "describe-config":
@@ -1148,6 +1399,9 @@ class RTVIProcessor(FrameProcessor):
await self._handle_update_config(message.id, update_config) await self._handle_update_config(message.id, update_config)
case "disconnect-bot": case "disconnect-bot":
await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
case "client-message":
data = RTVIRawClientMessageData.model_validate(message.data)
await self._handle_client_message(message.id, data)
case "action": case "action":
action = RTVIActionRun.model_validate(message.data) action = RTVIActionRun.model_validate(message.data)
action_frame = RTVIActionFrame(message_id=message.id, rtvi_action_run=action) action_frame = RTVIActionFrame(message_id=message.id, rtvi_action_run=action)
@@ -1155,6 +1409,9 @@ class RTVIProcessor(FrameProcessor):
case "llm-function-call-result": case "llm-function-call-result":
data = RTVILLMFunctionCallResultData.model_validate(message.data) data = RTVILLMFunctionCallResultData.model_validate(message.data)
await self._handle_function_call_result(data) await self._handle_function_call_result(data)
case "append-to-context":
data = RTVIAppendToContextData.model_validate(message.data)
await self._handle_update_context(data)
case "raw-audio" | "raw-audio-batch": case "raw-audio" | "raw-audio-batch":
await self._handle_audio_buffer(message.data) await self._handle_audio_buffer(message.data)
@@ -1168,9 +1425,20 @@ class RTVIProcessor(FrameProcessor):
await self._send_error_response(message.id, f"Exception processing message: {e}") await self._send_error_response(message.id, f"Exception processing message: {e}")
logger.warning(f"Exception processing message: {e}") logger.warning(f"Exception processing message: {e}")
async def _handle_client_ready(self, request_id: str): async def _handle_client_ready(self, request_id: str, data: RTVIClientReadyData | None):
"""Handle a client-ready message.""" """Handle the client-ready message from the client."""
logger.debug("Received client-ready") version = data.version if data else "unknown"
logger.debug(f"Received client-ready: version {version}")
if version == "unknown":
self._client_version = [0, 3, 0] # Default to 0.3.0 if unknown
else:
try:
self._client_version = [int(v) for v in version.split(".")]
except ValueError:
logger.warning(f"Invalid client version format: {version}")
self._client_version = [0, 3, 0]
about = data.about if data else {"library": "unknown"}
logger.debug(f"Client Details: {about}")
if self._input_transport: if self._input_transport:
await self._input_transport.start_audio_in_streaming() await self._input_transport.start_audio_in_streaming()
@@ -1201,18 +1469,45 @@ class RTVIProcessor(FrameProcessor):
async def _handle_describe_config(self, request_id: str): async def _handle_describe_config(self, request_id: str):
"""Handle a describe-config request.""" """Handle a describe-config request."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.",
DeprecationWarning,
)
services = list(self._registered_services.values()) services = list(self._registered_services.values())
message = RTVIDescribeConfig(id=request_id, data=RTVIDescribeConfigData(config=services)) message = RTVIDescribeConfig(id=request_id, data=RTVIDescribeConfigData(config=services))
await self._push_transport_message(message) await self._push_transport_message(message)
async def _handle_describe_actions(self, request_id: str): async def _handle_describe_actions(self, request_id: str):
"""Handle a describe-actions request.""" """Handle a describe-actions request."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"The Actions API is deprecated, use custom server and client messages instead.",
DeprecationWarning,
)
actions = list(self._registered_actions.values()) actions = list(self._registered_actions.values())
message = RTVIDescribeActions(id=request_id, data=RTVIDescribeActionsData(actions=actions)) message = RTVIDescribeActions(id=request_id, data=RTVIDescribeActionsData(actions=actions))
await self._push_transport_message(message) await self._push_transport_message(message)
async def _handle_get_config(self, request_id: str): async def _handle_get_config(self, request_id: str):
"""Handle a get-config request.""" """Handle a get-config request."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.",
DeprecationWarning,
)
message = RTVIConfigResponse(id=request_id, data=self._config) message = RTVIConfigResponse(id=request_id, data=self._config)
await self._push_transport_message(message) await self._push_transport_message(message)
@@ -1230,6 +1525,15 @@ class RTVIProcessor(FrameProcessor):
async def _update_service_config(self, config: RTVIServiceConfig): async def _update_service_config(self, config: RTVIServiceConfig):
"""Update configuration for a specific service.""" """Update configuration for a specific service."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.",
DeprecationWarning,
)
service = self._registered_services[config.service] service = self._registered_services[config.service]
for option in config.options: for option in config.options:
handler = service._options_dict[option.name].handler handler = service._options_dict[option.name].handler
@@ -1238,6 +1542,15 @@ class RTVIProcessor(FrameProcessor):
async def _update_config(self, data: RTVIConfig, interrupt: bool): async def _update_config(self, data: RTVIConfig, interrupt: bool):
"""Update the RTVI configuration.""" """Update the RTVI configuration."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
warnings.warn(
"Configuration helpers are deprecated. If your application needs this behavior, use custom server and client messages.",
DeprecationWarning,
)
if interrupt: if interrupt:
await self.interrupt_bot() await self.interrupt_bot()
for service_config in data.config: for service_config in data.config:
@@ -1248,6 +1561,33 @@ class RTVIProcessor(FrameProcessor):
await self._update_config(RTVIConfig(config=data.config), data.interrupt) await self._update_config(RTVIConfig(config=data.config), data.interrupt)
await self._handle_get_config(request_id) await self._handle_get_config(request_id)
async def _handle_update_context(self, data: RTVIAppendToContextData):
if data.run_immediately:
await self.interrupt_bot()
frame = LLMMessagesAppendFrame(
messages=[{"role": data.role, "content": data.content}],
run_llm=data.run_immediately,
)
await self.push_frame(frame)
async def _handle_client_message(self, msg_id: str, data: RTVIRawClientMessageData):
"""Handle a client message frame."""
if not data:
await self._send_error_response(msg_id, "Malformed client message")
return
# Create a RTVIClientMessageFrame to push the message
frame = RTVIClientMessageFrame(msg_id=msg_id, type=data.t, data=data.d)
await self.push_frame(frame)
await self._call_event_handler(
"on_client_message",
RTVIClientMessage(
msg_id=msg_id,
type=data.t,
data=data.d,
),
)
async def _handle_function_call_result(self, data): async def _handle_function_call_result(self, data):
"""Handle a function call result from the client.""" """Handle a function call result from the client."""
frame = FunctionCallResultFrame( frame = FunctionCallResultFrame(
@@ -1278,12 +1618,19 @@ class RTVIProcessor(FrameProcessor):
async def _send_bot_ready(self): async def _send_bot_ready(self):
"""Send the bot-ready message to the client.""" """Send the bot-ready message to the client."""
config = None
if self._client_version[0] < 1:
config = self._config.config
message = RTVIBotReady( message = RTVIBotReady(
id=self._client_ready_id, id=self._client_ready_id,
data=RTVIBotReadyData(version=RTVI_PROTOCOL_VERSION, config=self._config.config), data=RTVIBotReadyData(version=RTVI_PROTOCOL_VERSION, config=config),
) )
await self._push_transport_message(message) await self._push_transport_message(message)
async def _send_server_message(self, message: RTVIServerMessage | RTVIServerResponse):
"""Send a message or response to the client."""
await self._push_transport_message(message)
async def _send_error_frame(self, frame: ErrorFrame): async def _send_error_frame(self, frame: ErrorFrame):
"""Send an error frame as an RTVI error message.""" """Send an error frame as an RTVI error message."""
if self._errors_enabled: if self._errors_enabled:

View File

@@ -15,6 +15,8 @@ from pipecat.frames.frames import (
CancelFrame, CancelFrame,
EndFrame, EndFrame,
Frame, Frame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
StartFrame, StartFrame,
UserStartedSpeakingFrame, UserStartedSpeakingFrame,
UserStoppedSpeakingFrame, UserStoppedSpeakingFrame,
@@ -168,6 +170,13 @@ class UserIdleProcessor(FrameProcessor):
self._idle_event.set() self._idle_event.set()
elif isinstance(frame, BotSpeakingFrame): elif isinstance(frame, BotSpeakingFrame):
self._idle_event.set() self._idle_event.set()
elif isinstance(frame, FunctionCallInProgressFrame):
# Function calls can take longer than the timeout, so we want to prevent idle callbacks
self._interrupted = True
self._idle_event.set()
elif isinstance(frame, FunctionCallResultFrame):
self._interrupted = False
self._idle_event.set()
async def cleanup(self) -> None: async def cleanup(self) -> None:
"""Cleans up resources when processor is shutting down.""" """Cleans up resources when processor is shutting down."""

View File

@@ -108,6 +108,10 @@ class ExotelFrameSerializer(FrameSerializer):
serialized_data = await self._output_resampler.resample( serialized_data = await self._output_resampler.resample(
data, frame.sample_rate, self._exotel_sample_rate data, frame.sample_rate, self._exotel_sample_rate
) )
if serialized_data is None or len(serialized_data) == 0:
# Ignoring in case we don't have audio
return None
payload = base64.b64encode(serialized_data).decode("ascii") payload = base64.b64encode(serialized_data).decode("ascii")
answer = { answer = {
@@ -144,6 +148,9 @@ class ExotelFrameSerializer(FrameSerializer):
self._exotel_sample_rate, self._exotel_sample_rate,
self._sample_rate, self._sample_rate,
) )
if deserialized_data is None or len(deserialized_data) == 0:
# Ignoring in case we don't have audio
return None
# Input: Exotel takes PCM data, so just resample to match sample_rate # Input: Exotel takes PCM data, so just resample to match sample_rate
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(

View File

@@ -132,6 +132,10 @@ class PlivoFrameSerializer(FrameSerializer):
serialized_data = await pcm_to_ulaw( serialized_data = await pcm_to_ulaw(
data, frame.sample_rate, self._plivo_sample_rate, self._output_resampler data, frame.sample_rate, self._plivo_sample_rate, self._output_resampler
) )
if serialized_data is None or len(serialized_data) == 0:
# Ignoring in case we don't have audio
return None
payload = base64.b64encode(serialized_data).decode("utf-8") payload = base64.b64encode(serialized_data).decode("utf-8")
answer = { answer = {
"event": "playAudio", "event": "playAudio",
@@ -227,6 +231,10 @@ class PlivoFrameSerializer(FrameSerializer):
deserialized_data = await ulaw_to_pcm( deserialized_data = await ulaw_to_pcm(
payload, self._plivo_sample_rate, self._sample_rate, self._input_resampler payload, self._plivo_sample_rate, self._sample_rate, self._input_resampler
) )
if deserialized_data is None or len(deserialized_data) == 0:
# Ignoring in case we don't have audio
return None
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate
) )

View File

@@ -155,6 +155,10 @@ class TelnyxFrameSerializer(FrameSerializer):
else: else:
raise ValueError(f"Unsupported encoding: {self._params.inbound_encoding}") raise ValueError(f"Unsupported encoding: {self._params.inbound_encoding}")
if serialized_data is None or len(serialized_data) == 0:
# Ignoring in case we don't have audio
return None
payload = base64.b64encode(serialized_data).decode("utf-8") payload = base64.b64encode(serialized_data).decode("utf-8")
answer = { answer = {
"event": "media", "event": "media",
@@ -262,6 +266,10 @@ class TelnyxFrameSerializer(FrameSerializer):
else: else:
raise ValueError(f"Unsupported encoding: {self._params.outbound_encoding}") raise ValueError(f"Unsupported encoding: {self._params.outbound_encoding}")
if deserialized_data is None or len(deserialized_data) == 0:
# Ignoring in case we don't have audio
return None
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate
) )

View File

@@ -132,6 +132,10 @@ class TwilioFrameSerializer(FrameSerializer):
serialized_data = await pcm_to_ulaw( serialized_data = await pcm_to_ulaw(
data, frame.sample_rate, self._twilio_sample_rate, self._output_resampler data, frame.sample_rate, self._twilio_sample_rate, self._output_resampler
) )
if serialized_data is None or len(serialized_data) == 0:
# Ignoring in case we don't have audio
return None
payload = base64.b64encode(serialized_data).decode("utf-8") payload = base64.b64encode(serialized_data).decode("utf-8")
answer = { answer = {
"event": "media", "event": "media",
@@ -185,8 +189,26 @@ class TwilioFrameSerializer(FrameSerializer):
async with session.post(endpoint, auth=auth, data=params) as response: async with session.post(endpoint, auth=auth, data=params) as response:
if response.status == 200: if response.status == 200:
logger.info(f"Successfully terminated Twilio call {call_sid}") logger.info(f"Successfully terminated Twilio call {call_sid}")
elif response.status == 404:
# Handle the case where the call has already ended
# Error code 20404: "The requested resource was not found"
# Source: https://www.twilio.com/docs/errors/20404
try:
error_data = await response.json()
if error_data.get("code") == 20404:
logger.debug(f"Twilio call {call_sid} was already terminated")
return
except:
pass # Fall through to log the raw error
# Log other 404 errors
error_text = await response.text()
logger.error(
f"Failed to terminate Twilio call {call_sid}: "
f"Status {response.status}, Response: {error_text}"
)
else: else:
# Get the error details for better debugging # Log other errors
error_text = await response.text() error_text = await response.text()
logger.error( logger.error(
f"Failed to terminate Twilio call {call_sid}: " f"Failed to terminate Twilio call {call_sid}: "
@@ -217,6 +239,10 @@ class TwilioFrameSerializer(FrameSerializer):
deserialized_data = await ulaw_to_pcm( deserialized_data = await ulaw_to_pcm(
payload, self._twilio_sample_rate, self._sample_rate, self._input_resampler payload, self._twilio_sample_rate, self._sample_rate, self._input_resampler
) )
if deserialized_data is None or len(deserialized_data) == 0:
# Ignoring in case we don't have audio
return None
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate audio=deserialized_data, num_channels=1, sample_rate=self._sample_rate
) )

View File

@@ -55,7 +55,7 @@ from pipecat.services.llm_service import LLMService
from pipecat.utils.tracing.service_decorators import traced_llm from pipecat.utils.tracing.service_decorators import traced_llm
try: try:
import boto3 import aioboto3
import httpx import httpx
from botocore.config import Config from botocore.config import Config
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
@@ -749,13 +749,17 @@ class AWSBedrockLLMService(LLMService):
read_timeout=300, # 5 minutes read_timeout=300, # 5 minutes
retries={"max_attempts": 3}, retries={"max_attempts": 3},
) )
session = boto3.Session(
aws_access_key_id=aws_access_key, self._aws_session = aioboto3.Session()
aws_secret_access_key=aws_secret_key,
aws_session_token=aws_session_token, # Store AWS session parameters for creating client in async context
region_name=aws_region, self._aws_params = {
) "aws_access_key_id": aws_access_key,
self._client = session.client(service_name="bedrock-runtime", config=client_config) "aws_secret_access_key": aws_secret_key,
"aws_session_token": aws_session_token,
"region_name": aws_region,
"config": client_config,
}
self.set_model_name(model) self.set_model_name(model)
self._settings = { self._settings = {
@@ -903,70 +907,74 @@ class AWSBedrockLLMService(LLMService):
logger.debug(f"Calling AWS Bedrock model with: {request_params}") logger.debug(f"Calling AWS Bedrock model with: {request_params}")
# Call AWS Bedrock with streaming async with self._aws_session.client(
response = self._client.converse_stream(**request_params) service_name="bedrock-runtime", **self._aws_params
) as client:
# Call AWS Bedrock with streaming
response = await client.converse_stream(**request_params)
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
# Process the streaming response # Process the streaming response
tool_use_block = None tool_use_block = None
json_accumulator = "" json_accumulator = ""
function_calls = [] function_calls = []
for event in response["stream"]:
self.reset_watchdog()
# Handle text content async for event in response["stream"]:
if "contentBlockDelta" in event: self.reset_watchdog()
delta = event["contentBlockDelta"]["delta"]
if "text" in delta:
await self.push_frame(LLMTextFrame(delta["text"]))
completion_tokens_estimate += self._estimate_tokens(delta["text"])
elif "toolUse" in delta and "input" in delta["toolUse"]:
# Handle partial JSON for tool use
json_accumulator += delta["toolUse"]["input"]
completion_tokens_estimate += self._estimate_tokens(
delta["toolUse"]["input"]
)
# Handle tool use start # Handle text content
elif "contentBlockStart" in event: if "contentBlockDelta" in event:
content_block_start = event["contentBlockStart"]["start"] delta = event["contentBlockDelta"]["delta"]
if "toolUse" in content_block_start: if "text" in delta:
tool_use_block = { await self.push_frame(LLMTextFrame(delta["text"]))
"id": content_block_start["toolUse"].get("toolUseId", ""), completion_tokens_estimate += self._estimate_tokens(delta["text"])
"name": content_block_start["toolUse"].get("name", ""), elif "toolUse" in delta and "input" in delta["toolUse"]:
} # Handle partial JSON for tool use
json_accumulator = "" json_accumulator += delta["toolUse"]["input"]
completion_tokens_estimate += self._estimate_tokens(
delta["toolUse"]["input"]
)
# Handle message completion with tool use # Handle tool use start
elif "messageStop" in event and "stopReason" in event["messageStop"]: elif "contentBlockStart" in event:
if event["messageStop"]["stopReason"] == "tool_use" and tool_use_block: content_block_start = event["contentBlockStart"]["start"]
try: if "toolUse" in content_block_start:
arguments = json.loads(json_accumulator) if json_accumulator else {} tool_use_block = {
"id": content_block_start["toolUse"].get("toolUseId", ""),
"name": content_block_start["toolUse"].get("name", ""),
}
json_accumulator = ""
# Only call function if it's not the no_operation tool # Handle message completion with tool use
if not using_noop_tool: elif "messageStop" in event and "stopReason" in event["messageStop"]:
function_calls.append( if event["messageStop"]["stopReason"] == "tool_use" and tool_use_block:
FunctionCallFromLLM( try:
context=context, arguments = json.loads(json_accumulator) if json_accumulator else {}
tool_call_id=tool_use_block["id"],
function_name=tool_use_block["name"], # Only call function if it's not the no_operation tool
arguments=arguments, if not using_noop_tool:
function_calls.append(
FunctionCallFromLLM(
context=context,
tool_call_id=tool_use_block["id"],
function_name=tool_use_block["name"],
arguments=arguments,
)
) )
) else:
else: logger.debug("Ignoring no_operation tool call")
logger.debug("Ignoring no_operation tool call") except json.JSONDecodeError:
except json.JSONDecodeError: logger.error(f"Failed to parse tool arguments: {json_accumulator}")
logger.error(f"Failed to parse tool arguments: {json_accumulator}")
# Handle usage metrics if available # Handle usage metrics if available
if "metadata" in event and "usage" in event["metadata"]: if "metadata" in event and "usage" in event["metadata"]:
usage = event["metadata"]["usage"] usage = event["metadata"]["usage"]
prompt_tokens += usage.get("inputTokens", 0) prompt_tokens += usage.get("inputTokens", 0)
completion_tokens += usage.get("outputTokens", 0) completion_tokens += usage.get("outputTokens", 0)
cache_read_input_tokens += usage.get("cacheReadInputTokens", 0) cache_read_input_tokens += usage.get("cacheReadInputTokens", 0)
cache_creation_input_tokens += usage.get("cacheWriteInputTokens", 0) cache_creation_input_tokens += usage.get("cacheWriteInputTokens", 0)
await self.run_function_calls(function_calls) await self.run_function_calls(function_calls)
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -30,7 +30,7 @@ from pipecat.transcriptions.language import Language
from pipecat.utils.tracing.service_decorators import traced_tts from pipecat.utils.tracing.service_decorators import traced_tts
try: try:
import boto3 import aioboto3
from botocore.exceptions import BotoCoreError, ClientError from botocore.exceptions import BotoCoreError, ClientError
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
logger.error(f"Exception: {e}") logger.error(f"Exception: {e}")
@@ -177,13 +177,25 @@ class AWSPollyTTSService(TTSService):
params = params or AWSPollyTTSService.InputParams() params = params or AWSPollyTTSService.InputParams()
self._polly_client = boto3.client( # Get credentials from environment variables if not provided
"polly", self._aws_params = {
aws_access_key_id=aws_access_key_id, "aws_access_key_id": aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=api_key, "aws_secret_access_key": api_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
aws_session_token=aws_session_token, "aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
region_name=region, "region_name": region or os.getenv("AWS_REGION", "us-east-1"),
) }
# Validate that we have the required credentials
if (
not self._aws_params["aws_access_key_id"]
or not self._aws_params["aws_secret_access_key"]
):
raise ValueError(
"AWS credentials not found. Please provide them either through constructor parameters "
"or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables."
)
self._aws_session = aioboto3.Session()
self._settings = { self._settings = {
"engine": params.engine, "engine": params.engine,
"language": self.language_to_service_language(params.language) "language": self.language_to_service_language(params.language)
@@ -199,24 +211,6 @@ class AWSPollyTTSService(TTSService):
self.set_voice(voice_id) self.set_voice(voice_id)
# Get credentials from environment variables if not provided
self._credentials = {
"aws_access_key_id": aws_access_key_id or os.getenv("AWS_ACCESS_KEY_ID"),
"aws_secret_access_key": api_key or os.getenv("AWS_SECRET_ACCESS_KEY"),
"aws_session_token": aws_session_token or os.getenv("AWS_SESSION_TOKEN"),
"region": region or os.getenv("AWS_REGION", "us-east-1"),
}
# Validate that we have the required credentials
if (
not self._credentials["aws_access_key_id"]
or not self._credentials["aws_secret_access_key"]
):
raise ValueError(
"AWS credentials not found. Please provide them either through constructor parameters "
"or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables."
)
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.
@@ -279,14 +273,6 @@ class AWSPollyTTSService(TTSService):
Yields: Yields:
Frame: Audio frames containing the synthesized speech. Frame: Audio frames containing the synthesized speech.
""" """
def read_audio_data(**args):
response = self._polly_client.synthesize_speech(**args)
if "AudioStream" in response:
audio_data = response["AudioStream"].read()
return audio_data
return None
logger.debug(f"{self}: Generating TTS [{text}]") logger.debug(f"{self}: Generating TTS [{text}]")
try: try:
@@ -309,30 +295,32 @@ class AWSPollyTTSService(TTSService):
# Filter out None values # Filter out None values
filtered_params = {k: v for k, v in params.items() if v is not None} filtered_params = {k: v for k, v in params.items() if v is not None}
audio_data = await asyncio.to_thread(read_audio_data, **filtered_params) async with self._aws_session.client("polly", **self._aws_params) as polly:
response = await polly.synthesize_speech(**filtered_params)
if "AudioStream" in response:
# Get the streaming body and read it
stream = response["AudioStream"]
audio_data = await stream.read()
else:
logger.error(f"{self} No audio stream in response")
audio_data = None
if not audio_data: audio_data = await self._resampler.resample(audio_data, 16000, self.sample_rate)
logger.error(f"{self} No audio data returned")
yield None
return
audio_data = await self._resampler.resample(audio_data, 16000, self.sample_rate) await self.start_tts_usage_metrics(text)
await self.start_tts_usage_metrics(text) yield TTSStartedFrame()
yield TTSStartedFrame() CHUNK_SIZE = self.chunk_size
CHUNK_SIZE = self.chunk_size for i in range(0, len(audio_data), CHUNK_SIZE):
chunk = audio_data[i : i + CHUNK_SIZE]
for i in range(0, len(audio_data), CHUNK_SIZE): if len(chunk) > 0:
chunk = audio_data[i : i + CHUNK_SIZE] await self.stop_ttfb_metrics()
if len(chunk) > 0: frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
await self.stop_ttfb_metrics() yield frame
frame = TTSAudioRawFrame(chunk, self.sample_rate, 1)
yield frame
yield TTSStoppedFrame()
yield TTSStoppedFrame()
except (BotoCoreError, ClientError) as error: except (BotoCoreError, ClientError) as error:
logger.exception(f"{self} error generating TTS: {error}") logger.exception(f"{self} error generating TTS: {error}")
error_message = f"AWS Polly TTS error: {str(error)}" error_message = f"AWS Polly TTS error: {str(error)}"

View File

@@ -474,7 +474,6 @@ class AWSNovaSonicLLMService(LLMService):
# If we need to, send assistant response trigger (depends on self._connected_time) # If we need to, send assistant response trigger (depends on self._connected_time)
if self._triggering_assistant_response: if self._triggering_assistant_response:
await self._send_assistant_response_trigger() await self._send_assistant_response_trigger()
self._triggering_assistant_response = False
async def _disconnect(self): async def _disconnect(self):
try: try:
@@ -1105,7 +1104,6 @@ class AWSNovaSonicLLMService(LLMService):
# Send the trigger audio, if we're fully connected and set up # Send the trigger audio, if we're fully connected and set up
if self._connected_time is not None: if self._connected_time is not None:
await self._send_assistant_response_trigger() await self._send_assistant_response_trigger()
self._triggering_assistant_response = False
async def _send_assistant_response_trigger(self): async def _send_assistant_response_trigger(self):
if ( if (
@@ -1113,46 +1111,51 @@ class AWSNovaSonicLLMService(LLMService):
): # should never happen ): # should never happen
return return
logger.debug("Sending assistant response trigger...") try:
logger.debug("Sending assistant response trigger...")
chunk_duration = 0.02 # what we might get from InputAudioRawFrame chunk_duration = 0.02 # what we might get from InputAudioRawFrame
chunk_size = int( chunk_size = int(
chunk_duration chunk_duration
* self._params.input_sample_rate * self._params.input_sample_rate
* self._params.input_channel_count * self._params.input_channel_count
* (self._params.input_sample_size / 8) * (self._params.input_sample_size / 8)
) # e.g. 0.02 seconds of 16-bit (2-byte) PCM mono audio at 16kHz is 640 bytes ) # e.g. 0.02 seconds of 16-bit (2-byte) PCM mono audio at 16kHz is 640 bytes
# Lead with a bit of blank audio, if needed. # Lead with a bit of blank audio, if needed.
# It seems like the LLM can't quite "hear" the first little bit of audio sent on a # It seems like the LLM can't quite "hear" the first little bit of audio sent on a
# connection. # connection.
current_time = time.time() current_time = time.time()
max_blank_audio_duration = 0.5 max_blank_audio_duration = 0.5
blank_audio_duration = ( blank_audio_duration = (
max_blank_audio_duration - (current_time - self._connected_time) max_blank_audio_duration - (current_time - self._connected_time)
if self._connected_time is not None if self._connected_time is not None
and (current_time - self._connected_time) < max_blank_audio_duration and (current_time - self._connected_time) < max_blank_audio_duration
else None else None
)
if blank_audio_duration:
logger.debug(
f"Leading assistant response trigger with {blank_audio_duration}s of blank audio"
) )
blank_audio_chunk = b"\x00" * chunk_size if blank_audio_duration:
num_chunks = int(blank_audio_duration / chunk_duration) logger.debug(
for _ in range(num_chunks): f"Leading assistant response trigger with {blank_audio_duration}s of blank audio"
await self._send_user_audio_event(blank_audio_chunk) )
await asyncio.sleep(chunk_duration) blank_audio_chunk = b"\x00" * chunk_size
num_chunks = int(blank_audio_duration / chunk_duration)
for _ in range(num_chunks):
await self._send_user_audio_event(blank_audio_chunk)
await asyncio.sleep(chunk_duration)
# Send trigger audio # Send trigger audio
# NOTE: this audio *will* be transcribed and eventually make it into the context. That's OK: # NOTE: this audio *will* be transcribed and eventually make it into the context. That's OK:
# if we ever need to seed this service again with context it would make sense to include it # if we ever need to seed this service again with context it would make sense to include it
# since the instruction (i.e. the "wait for the trigger" instruction) will be part of the # since the instruction (i.e. the "wait for the trigger" instruction) will be part of the
# context as well. # context as well.
audio_chunks = [ audio_chunks = [
self._assistant_response_trigger_audio[i : i + chunk_size] self._assistant_response_trigger_audio[i : i + chunk_size]
for i in range(0, len(self._assistant_response_trigger_audio), chunk_size) for i in range(0, len(self._assistant_response_trigger_audio), chunk_size)
] ]
for chunk in audio_chunks: for chunk in audio_chunks:
await self._send_user_audio_event(chunk) await self._send_user_audio_event(chunk)
await asyncio.sleep(chunk_duration) await asyncio.sleep(chunk_duration)
finally:
# We need to clean up in case sending the trigger was cancelled, e.g. in the case of a user interruption.
# (An asyncio.CancelledError would be raised in that case.)
self._triggering_assistant_response = False

View File

@@ -121,6 +121,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
container: str = "raw", container: str = "raw",
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
text_aggregator: Optional[BaseTextAggregator] = None, text_aggregator: Optional[BaseTextAggregator] = None,
aggregate_sentences: Optional[bool] = True,
**kwargs, **kwargs,
): ):
"""Initialize the Cartesia TTS service. """Initialize the Cartesia TTS service.
@@ -136,6 +137,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
container: Audio container format. container: Audio container format.
params: Additional input parameters for voice customization. params: Additional input parameters for voice customization.
text_aggregator: Custom text aggregator for processing input text. text_aggregator: Custom text aggregator for processing input text.
aggregate_sentences: Whether to aggregate sentences within the TTSService.
**kwargs: Additional arguments passed to the parent service. **kwargs: Additional arguments passed to the parent service.
""" """
# Aggregating sentences still gives cleaner-sounding results and fewer # Aggregating sentences still gives cleaner-sounding results and fewer
@@ -149,7 +151,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
# can use those to generate text frames ourselves aligned with the # can use those to generate text frames ourselves aligned with the
# playout timing of the audio! # playout timing of the audio!
super().__init__( super().__init__(
aggregate_sentences=True, aggregate_sentences=aggregate_sentences,
push_text_frames=False, push_text_frames=False,
pause_frame_processing=True, pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,

View File

@@ -238,6 +238,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
url: str = "wss://api.elevenlabs.io", url: str = "wss://api.elevenlabs.io",
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
aggregate_sentences: Optional[bool] = True,
**kwargs, **kwargs,
): ):
"""Initialize the ElevenLabs TTS service. """Initialize the ElevenLabs TTS service.
@@ -249,6 +250,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
url: WebSocket URL for ElevenLabs TTS API. url: WebSocket URL for ElevenLabs TTS API.
sample_rate: Audio sample rate. If None, uses default. sample_rate: Audio sample rate. If None, uses default.
params: Additional input parameters for voice customization. params: Additional input parameters for voice customization.
aggregate_sentences: Whether to aggregate sentences within the TTSService.
**kwargs: Additional arguments passed to the parent service. **kwargs: Additional arguments passed to the parent service.
""" """
# Aggregating sentences still gives cleaner-sounding results and fewer # Aggregating sentences still gives cleaner-sounding results and fewer
@@ -266,7 +268,7 @@ class ElevenLabsTTSService(AudioContextWordTTSService):
# speaking for a while, so we want the parent class to send TTSStopFrame # speaking for a while, so we want the parent class to send TTSStopFrame
# after a short period not receiving any audio. # after a short period not receiving any audio.
super().__init__( super().__init__(
aggregate_sentences=True, aggregate_sentences=aggregate_sentences,
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
pause_frame_processing=True, pause_frame_processing=True,

View File

@@ -627,9 +627,9 @@ class GoogleLLMContext(OpenAILLMContext):
# Check if we only have function-related messages (no regular text) # Check if we only have function-related messages (no regular text)
has_regular_messages = any( has_regular_messages = any(
len(msg.parts) == 1 len(msg.parts) == 1
and not getattr(msg.parts[0], "text", None) and getattr(msg.parts[0], "text", None)
and getattr(msg.parts[0], "function_call", None) and not getattr(msg.parts[0], "function_call", None)
and getattr(msg.parts[0], "function_response", None) and not getattr(msg.parts[0], "function_response", None)
for msg in self._messages for msg in self._messages
) )

View File

@@ -95,7 +95,7 @@ class LmntTTSService(InterruptibleTTSService):
voice_id: str, voice_id: str,
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
language: Language = Language.EN, language: Language = Language.EN,
model: str = "aurora", model: str = "blizzard",
**kwargs, **kwargs,
): ):
"""Initialize the LMNT TTS service. """Initialize the LMNT TTS service.
@@ -105,7 +105,7 @@ class LmntTTSService(InterruptibleTTSService):
voice_id: ID of the voice to use for synthesis. voice_id: ID of the voice to use for synthesis.
sample_rate: Audio sample rate. If None, uses default. sample_rate: Audio sample rate. If None, uses default.
language: Language for synthesis. Defaults to English. language: Language for synthesis. Defaults to English.
model: TTS model to use. Defaults to "aurora". model: TTS model to use. Defaults to "blizzard".
**kwargs: Additional arguments passed to parent InterruptibleTTSService. **kwargs: Additional arguments passed to parent InterruptibleTTSService.
""" """
super().__init__( super().__init__(

View File

@@ -69,6 +69,7 @@ class Mem0MemoryService(FrameProcessor):
agent_id: Optional[str] = None, agent_id: Optional[str] = None,
run_id: Optional[str] = None, run_id: Optional[str] = None,
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
host: Optional[str] = None,
): ):
"""Initialize the Mem0 memory service. """Initialize the Mem0 memory service.
@@ -79,6 +80,7 @@ class Mem0MemoryService(FrameProcessor):
agent_id: The agent ID to associate with memories in Mem0. agent_id: The agent ID to associate with memories in Mem0.
run_id: The run ID to associate with memories in Mem0. run_id: The run ID to associate with memories in Mem0.
params: Configuration parameters for memory retrieval and storage. params: Configuration parameters for memory retrieval and storage.
host: The host of the Mem0 server.
Raises: Raises:
ValueError: If none of user_id, agent_id, or run_id are provided. ValueError: If none of user_id, agent_id, or run_id are provided.
@@ -92,7 +94,7 @@ class Mem0MemoryService(FrameProcessor):
if local_config: if local_config:
self.memory_client = Memory.from_config(local_config) self.memory_client = Memory.from_config(local_config)
else: else:
self.memory_client = MemoryClient(api_key=api_key) self.memory_client = MemoryClient(api_key=api_key, host=host)
# At least one of user_id, agent_id, or run_id must be provided # At least one of user_id, agent_id, or run_id must be provided
if not any([user_id, agent_id, run_id]): if not any([user_id, agent_id, run_id]):
raise ValueError("At least one of user_id, agent_id, or run_id must be provided") raise ValueError("At least one of user_id, agent_id, or run_id must be provided")

View File

@@ -106,10 +106,11 @@ class NeuphonicTTSService(InterruptibleTTSService):
*, *,
api_key: str, api_key: str,
voice_id: Optional[str] = None, voice_id: Optional[str] = None,
url: str = "wss://api.neuphonic.com", url: str = "wss://eu-west-1.api.neuphonic.com",
sample_rate: Optional[int] = 22050, sample_rate: Optional[int] = 22050,
encoding: str = "pcm_linear", encoding: str = "pcm_linear",
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
aggregate_sentences: Optional[bool] = True,
**kwargs, **kwargs,
): ):
"""Initialize the Neuphonic TTS service. """Initialize the Neuphonic TTS service.
@@ -121,10 +122,11 @@ class NeuphonicTTSService(InterruptibleTTSService):
sample_rate: Audio sample rate in Hz. Defaults to 22050. sample_rate: Audio sample rate in Hz. Defaults to 22050.
encoding: Audio encoding format. Defaults to "pcm_linear". encoding: Audio encoding format. Defaults to "pcm_linear".
params: Additional input parameters for TTS configuration. params: Additional input parameters for TTS configuration.
aggregate_sentences: Whether to aggregate sentences within the TTSService.
**kwargs: Additional arguments passed to parent InterruptibleTTSService. **kwargs: Additional arguments passed to parent InterruptibleTTSService.
""" """
super().__init__( super().__init__(
aggregate_sentences=True, aggregate_sentences=aggregate_sentences,
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
stop_frame_timeout_s=2.0, stop_frame_timeout_s=2.0,
@@ -279,14 +281,18 @@ class NeuphonicTTSService(InterruptibleTTSService):
"voice_id": self._voice_id, "voice_id": self._voice_id,
} }
query_params = [f"api_key={self._api_key}"] query_params = []
for key, value in tts_config.items(): for key, value in tts_config.items():
if value is not None: if value is not None:
query_params.append(f"{key}={value}") query_params.append(f"{key}={value}")
url = f"{self._url}/speak/{self._settings['lang_code']}?{'&'.join(query_params)}" url = f"{self._url}/speak/{self._settings['lang_code']}"
if query_params:
url += f"?{'&'.join(query_params)}"
self._websocket = await websockets.connect(url) headers = {"x-api-key": self._api_key}
self._websocket = await websockets.connect(url, extra_headers=headers)
except Exception as e: except Exception as e:
logger.error(f"{self} initialization error: {e}") logger.error(f"{self} initialization error: {e}")
self._websocket = None self._websocket = None
@@ -311,7 +317,7 @@ class NeuphonicTTSService(InterruptibleTTSService):
async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager): async for message in WatchdogAsyncIterator(self._websocket, manager=self.task_manager):
if isinstance(message, str): if isinstance(message, str):
msg = json.loads(message) msg = json.loads(message)
if msg.get("data", {}).get("audio") is not None: if msg.get("data") and msg["data"].get("audio"):
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
audio = base64.b64decode(msg["data"]["audio"]) audio = base64.b64decode(msg["data"]["audio"])
@@ -324,12 +330,19 @@ class NeuphonicTTSService(InterruptibleTTSService):
while True: while True:
self.reset_watchdog() self.reset_watchdog()
await asyncio.sleep(KEEPALIVE_SLEEP) await asyncio.sleep(KEEPALIVE_SLEEP)
await self._send_text("") await self._send_keepalive()
async def _send_keepalive(self):
"""Send keepalive message to maintain connection."""
if self._websocket:
# Send empty text for keepalive
msg = {"text": ""}
await self._websocket.send(json.dumps(msg))
async def _send_text(self, text: str): async def _send_text(self, text: str):
"""Send text to Neuphonic WebSocket for synthesis.""" """Send text to Neuphonic WebSocket for synthesis."""
if self._websocket: if self._websocket:
msg = {"text": text} msg = {"text": f"{text} <STOP>"}
logger.debug(f"Sending text to websocket: {msg}") logger.debug(f"Sending text to websocket: {msg}")
await self._websocket.send(json.dumps(msg)) await self._websocket.send(json.dumps(msg))

View File

@@ -6,6 +6,8 @@
"""OLLama LLM service implementation for Pipecat AI framework.""" """OLLama LLM service implementation for Pipecat AI framework."""
from loguru import logger
from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.llm import OpenAILLMService
@@ -16,12 +18,28 @@ class OLLamaLLMService(OpenAILLMService):
providing a compatible interface for running large language models locally. providing a compatible interface for running large language models locally.
""" """
def __init__(self, *, model: str = "llama2", base_url: str = "http://localhost:11434/v1"): def __init__(
self, *, model: str = "llama2", base_url: str = "http://localhost:11434/v1", **kwargs
):
"""Initialize OLLama LLM service. """Initialize OLLama LLM service.
Args: Args:
model: The OLLama model to use. Defaults to "llama2". model: The OLLama model to use. Defaults to "llama2".
base_url: The base URL for the OLLama API endpoint. base_url: The base URL for the OLLama API endpoint.
Defaults to "http://localhost:11434/v1". Defaults to "http://localhost:11434/v1".
**kwargs: Additional keyword arguments passed to OpenAILLMService.
""" """
super().__init__(model=model, base_url=base_url, api_key="ollama") super().__init__(model=model, base_url=base_url, api_key="ollama", **kwargs)
def create_client(self, base_url=None, **kwargs):
"""Create OpenAI-compatible client for Ollama.
Args:
base_url: The base URL for the API. If None, uses instance base_url.
**kwargs: Additional keyword arguments passed to the parent create_client method.
Returns:
An OpenAI-compatible client configured for Ollama.
"""
logger.debug(f"Creating Ollama client with api {base_url}")
return super().create_client(base_url, **kwargs)

View File

@@ -99,6 +99,7 @@ class RimeTTSService(AudioContextWordTTSService):
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
params: Optional[InputParams] = None, params: Optional[InputParams] = None,
text_aggregator: Optional[BaseTextAggregator] = None, text_aggregator: Optional[BaseTextAggregator] = None,
aggregate_sentences: Optional[bool] = True,
**kwargs, **kwargs,
): ):
"""Initialize Rime TTS service. """Initialize Rime TTS service.
@@ -111,11 +112,12 @@ class RimeTTSService(AudioContextWordTTSService):
sample_rate: Audio sample rate in Hz. sample_rate: Audio sample rate in Hz.
params: Additional configuration parameters. params: Additional configuration parameters.
text_aggregator: Custom text aggregator for processing input text. text_aggregator: Custom text aggregator for processing input text.
aggregate_sentences: Whether to aggregate sentences within the TTSService.
**kwargs: Additional arguments passed to parent class. **kwargs: Additional arguments passed to parent class.
""" """
# Initialize with parent class settings for proper frame handling # Initialize with parent class settings for proper frame handling
super().__init__( super().__init__(
aggregate_sentences=True, aggregate_sentences=aggregate_sentences,
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
pause_frame_processing=True, pause_frame_processing=True,

View File

@@ -279,7 +279,6 @@ class RivaSTTService(STTService):
streaming_config=self._config, streaming_config=self._config,
) )
for response in responses: for response in responses:
self.reset_watchdog()
if not response.results: if not response.results:
continue continue
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(

View File

View File

@@ -0,0 +1,396 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Soniox speech-to-text service implementation."""
import asyncio
import json
import time
from typing import AsyncGenerator, List, Optional
from loguru import logger
from pydantic import BaseModel
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
ErrorFrame,
Frame,
InterimTranscriptionFrame,
StartFrame,
TranscriptionFrame,
UserStoppedSpeakingFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.stt_service import STTService
from pipecat.transcriptions.language import Language
from pipecat.utils.time import time_now_iso8601
from pipecat.utils.tracing.service_decorators import traced_stt
try:
import websockets
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use Soniox, you need to `pip install pipecat-ai[soniox]`.")
raise Exception(f"Missing module: {e}")
KEEPALIVE_MESSAGE = '{"type": "keepalive"}'
FINALIZE_MESSAGE = '{"type": "finalize"}'
END_TOKEN = "<end>"
FINALIZED_TOKEN = "<fin>"
class SonioxInputParams(BaseModel):
"""Real-time transcription settings.
See Soniox WebSocket API documentation for more details:
https://soniox.com/docs/speech-to-text/api-reference/websocket-api#configuration-parameters
Parameters:
model: Model to use for transcription.
audio_format: Audio format to use for transcription.
num_channels: Number of channels to use for transcription.
language_hints: List of language hints to use for transcription.
context: Customization for transcription.
enable_non_final_tokens: Whether to enable non-final tokens. If false, only final tokens will be returned.
max_non_final_tokens_duration_ms: Maximum duration of non-final tokens.
client_reference_id: Client reference ID to use for transcription.
"""
model: str = "stt-rt-preview"
audio_format: Optional[str] = "pcm_s16le"
num_channels: Optional[int] = 1
language_hints: Optional[List[Language]] = None
context: Optional[str] = None
enable_non_final_tokens: Optional[bool] = True
max_non_final_tokens_duration_ms: Optional[int] = None
client_reference_id: Optional[str] = None
def is_end_token(token: dict) -> bool:
"""Determine if a token is an end token."""
return token["text"] == END_TOKEN or token["text"] == FINALIZED_TOKEN
def language_to_soniox_language(language: Language) -> str:
"""Pipecat Language enum uses same ISO 2-letter codes as Soniox, except with added regional variants.
For a list of all supported languages, see: https://soniox.com/docs/speech-to-text/core-concepts/supported-languages
"""
lang_str = str(language.value).lower()
if "-" in lang_str:
return lang_str.split("-")[0]
return lang_str
def _prepare_language_hints(
language_hints: Optional[List[Language]],
) -> Optional[List[str]]:
if language_hints is None:
return None
prepared_languages = [language_to_soniox_language(lang) for lang in language_hints]
# Remove duplicates (in case of language_hints with multiple regions).
return list(set(prepared_languages))
class SonioxSTTService(STTService):
"""Speech-to-Text service using Soniox's WebSocket API.
This service connects to Soniox's WebSocket API for real-time transcription
with support for multiple languages, custom context, speaker diarization,
and more.
For complete API documentation, see: https://soniox.com/docs/speech-to-text/api-reference/websocket-api
"""
def __init__(
self,
*,
api_key: str,
url: str = "wss://stt-rt.soniox.com/transcribe-websocket",
sample_rate: Optional[int] = None,
params: Optional[SonioxInputParams] = None,
vad_force_turn_endpoint: bool = False,
**kwargs,
):
"""Initialize the Soniox STT service.
Args:
api_key: Soniox API key.
url: Soniox WebSocket API URL.
sample_rate: Audio sample rate.
params: Additional configuration parameters, such as language hints, context and
speaker diarization.
vad_force_turn_endpoint: Listen to `UserStoppedSpeakingFrame` to send finalize message to Soniox. If disabled, Soniox will detect the end of the speech.
**kwargs: Additional arguments passed to the STTService.
"""
super().__init__(sample_rate=sample_rate, **kwargs)
params = params or SonioxInputParams()
self._api_key = api_key
self._url = url
self.set_model_name(params.model)
self._params = params
self._vad_force_turn_endpoint = vad_force_turn_endpoint
self._websocket = None
self._final_transcription_buffer = []
self._last_tokens_received: Optional[float] = None
self._receive_task = None
self._keepalive_task = None
async def start(self, frame: StartFrame):
"""Start the Soniox STT websocket connection.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
if self._websocket:
return
self._websocket = await websockets.connect(self._url)
if not self._websocket:
logger.error(f"Unable to connect to Soniox API at {self._url}")
# If vad_force_turn_endpoint is not enabled, we need to enable endpoint detection.
# Either one or the other is required.
enable_endpoint_detection = not self._vad_force_turn_endpoint
# Send the initial configuration message.
config = {
"api_key": self._api_key,
"model": self._model_name,
"audio_format": self._params.audio_format,
"num_channels": self._params.num_channels or 1,
"enable_endpoint_detection": enable_endpoint_detection,
"sample_rate": self.sample_rate,
"language_hints": _prepare_language_hints(self._params.language_hints),
"context": self._params.context,
"enable_non_final_tokens": self._params.enable_non_final_tokens,
"max_non_final_tokens_duration_ms": self._params.max_non_final_tokens_duration_ms,
"client_reference_id": self._params.client_reference_id,
}
# Send the configuration message.
await self._websocket.send(json.dumps(config))
if self._websocket and not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler())
if self._websocket and not self._keepalive_task:
self._keepalive_task = self.create_task(self._keepalive_task_handler())
async def _cleanup(self):
if self._keepalive_task:
await self.cancel_task(self._keepalive_task)
self._keepalive_task = None
if self._websocket:
await self._websocket.close()
self._websocket = None
if self._receive_task:
# Task cannot cancel itself. If task called _cleanup() we expect it to cancel itself.
if self._receive_task != asyncio.current_task():
await self.wait_for_task(self._receive_task)
self._receive_task = None
async def stop(self, frame: EndFrame):
"""Stop the Soniox STT websocket connection.
Stopping waits for the server to close the connection as we might receive
additional final tokens after sending the stop recording message.
Args:
frame: The end frame.
"""
await super().stop(frame)
await self._send_stop_recording()
async def cancel(self, frame: CancelFrame):
"""Cancel the Soniox STT websocket connection.
Compared to stop, this method closes the connection immediately without waiting
for the server to close it. This is useful when we want to stop the connection
immediately without waiting for the server to send any final tokens.
Args:
frame: The cancel frame.
"""
await super().cancel(frame)
await self._cleanup()
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
"""Send audio data to Soniox STT Service.
Args:
audio: Raw audio bytes to transcribe.
Yields:
Frame: None (transcription results come via WebSocket callbacks).
"""
await self.start_processing_metrics()
if self._websocket and not self._websocket.closed:
await self._websocket.send(audio)
await self.stop_processing_metrics()
yield None
@traced_stt
async def _handle_transcription(
self, transcript: str, is_final: bool, language: Optional[Language] = None
):
"""Handle a transcription result with tracing."""
pass
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Processes a frame of audio data, either buffering or transcribing it.
Args:
frame: The frame to process.
direction: The direction of frame processing.
"""
await super().process_frame(frame, direction)
if isinstance(frame, UserStoppedSpeakingFrame) and self._vad_force_turn_endpoint:
# Send finalize message to Soniox so we get the final tokens asap.
if self._websocket and not self._websocket.closed:
await self._websocket.send(FINALIZE_MESSAGE)
logger.debug(f"Triggered finalize event on: {frame.name=}, {direction=}")
async def _send_stop_recording(self):
"""Send stop recording message to Soniox."""
if self._websocket and not self._websocket.closed:
# Send stop recording message
await self._websocket.send("")
async def _keepalive_task_handler(self):
"""Connection has to be open all the time."""
try:
while True:
logger.debug("Sending keepalive message")
if self._websocket and not self._websocket.closed:
await self._websocket.send(KEEPALIVE_MESSAGE)
else:
logger.debug("WebSocket connection closed.")
break
await asyncio.sleep(5)
except websockets.exceptions.ConnectionClosed:
# Expected when closing the connection
logger.debug("WebSocket connection closed, keepalive task stopped.")
except Exception as e:
logger.error(f"{self} error (_keepalive_task_handler): {e}")
await self.push_error(ErrorFrame(f"{self} error (_keepalive_task_handler): {e}"))
async def _receive_task_handler(self):
if not self._websocket:
return
# Transcription frame will be only sent after we get the "endpoint" event.
self._final_transcription_buffer = []
async def send_endpoint_transcript():
if self._final_transcription_buffer:
text = "".join(map(lambda token: token["text"], self._final_transcription_buffer))
await self.push_frame(
TranscriptionFrame(
text=text,
user_id=self._user_id,
timestamp=time_now_iso8601(),
result=self._final_transcription_buffer,
)
)
await self._handle_transcription(text, is_final=True)
await self.stop_processing_metrics()
self._final_transcription_buffer = []
try:
async for message in self._websocket:
content = json.loads(message)
tokens = content["tokens"]
if tokens:
if len(tokens) == 1 and tokens[0]["text"] == FINALIZED_TOKEN:
# Ignore finalized token, prevent auto-finalize cycling.
pass
else:
# Got at least one token, so we can reset the auto finalize delay.
self._last_tokens_received = time.time()
# We will only send the final tokens after we get the "endpoint" event.
non_final_transcription = []
for token in tokens:
if token["is_final"]:
if is_end_token(token):
# Found an endpoint, tokens until here will be sent as transcript,
# the rest will be sent as interim tokens (even final tokens).
await send_endpoint_transcript()
else:
self._final_transcription_buffer.append(token)
else:
non_final_transcription.append(token)
if self._final_transcription_buffer or non_final_transcription:
final_text = "".join(
map(lambda token: token["text"], self._final_transcription_buffer)
)
non_final_text = "".join(
map(lambda token: token["text"], non_final_transcription)
)
await self.push_frame(
InterimTranscriptionFrame(
# Even final tokens are sent as interim tokens as we want to send
# nicely formatted messages - therefore waiting for the endpoint.
text=final_text + non_final_text,
user_id=self._user_id,
timestamp=time_now_iso8601(),
result=self._final_transcription_buffer + non_final_transcription,
)
)
error_code = content.get("error_code")
error_message = content.get("error_message")
if error_code or error_message:
# In case of error, still send the final transcript (if any remaining in the buffer).
await send_endpoint_transcript()
logger.error(
f"{self} error: {error_code} (_receive_task_handler) - {error_message}"
)
await self.push_error(
ErrorFrame(
f"{self} error: {error_code} (_receive_task_handler) - {error_message}"
)
)
finished = content.get("finished")
if finished:
# When finished, still send the final transcript (if any remaining in the buffer).
await send_endpoint_transcript()
logger.debug("Transcription finished.")
await self._cleanup()
return
except websockets.exceptions.ConnectionClosed:
# Expected when closing the connection.
pass
except Exception as e:
logger.error(f"{self} error: {e}")
await self.push_error(ErrorFrame(f"{self} error: {e}"))

Some files were not shown because too many files have changed in this diff Show More