Merge branch 'main' into groundingMetadata
This commit is contained in:
@@ -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
|
||||||
|
|||||||
118
CHANGELOG.md
118
CHANGELOG.md
@@ -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`.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ autodoc_mock_imports = [
|
|||||||
"openpipe",
|
"openpipe",
|
||||||
"simli",
|
"simli",
|
||||||
"soundfile",
|
"soundfile",
|
||||||
|
"soniox",
|
||||||
"pipecat_ai_krisp",
|
"pipecat_ai_krisp",
|
||||||
"pyaudio",
|
"pyaudio",
|
||||||
"_tkinter",
|
"_tkinter",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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=...
|
||||||
|
|
||||||
|
|||||||
60
examples/aws-strands/README.md
Normal file
60
examples/aws-strands/README.md
Normal 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
|
||||||
|
```
|
||||||
206
examples/aws-strands/black-box.py
Normal file
206
examples/aws-strands/black-box.py
Normal 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)
|
||||||
8
examples/aws-strands/env.example
Normal file
8
examples/aws-strands/env.example
Normal 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=
|
||||||
249
examples/aws-strands/explain-thinking.py
Normal file
249
examples/aws-strands/explain-thinking.py
Normal 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)
|
||||||
6
examples/aws-strands/requirements.txt
Normal file
6
examples/aws-strands/requirements.txt
Normal 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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
modal==0.71.3
|
modal==1.0.5
|
||||||
|
fastapi[all]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1289
examples/fal-smart-turn/client/package-lock.json
generated
1289
examples/fal-smart-turn/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
109
examples/foundational/07aa-interruptible-soniox.py
Normal file
109
examples/foundational/07aa-interruptible-soniox.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, 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)
|
||||||
81
examples/foundational/13i-soniox-transcription.py
Normal file
81
examples/foundational/13i-soniox-transcription.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, 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)
|
||||||
@@ -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()
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
</html>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="/src/app.js"></script>
|
|
||||||
<link rel="stylesheet" href="/src/style.css">
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
1394
examples/simple-chatbot/client/react/package-lock.json
generated
1394
examples/simple-chatbot/client/react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
563
examples/websocket/client/package-lock.json
generated
563
examples/websocket/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
1613
examples/word-wrangler-gemini-live/client/package-lock.json
generated
1613
examples/word-wrangler-gemini-live/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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__)),
|
||||||
|
|||||||
@@ -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" ]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
192
src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py
Normal file
192
src/pipecat/audio/turn/smart_turn/local_smart_turn_v2.py
Normal 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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
0
src/pipecat/services/soniox/__init__.py
Normal file
0
src/pipecat/services/soniox/__init__.py
Normal file
396
src/pipecat/services/soniox/stt.py
Normal file
396
src/pipecat/services/soniox/stt.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, 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
Reference in New Issue
Block a user