Merge branch 'main' into vl_fixing_deepgram_language_bug_#868

This commit is contained in:
Vaibhav159
2024-12-17 22:09:30 +05:30
17 changed files with 339 additions and 144 deletions

View File

@@ -9,15 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Add support for more languages to ElevenLabs (Arabic, Croatian, Filipino, - Pipecat now supports Python 3.13. We had a dependency on the `audioop` package
which was deprecated and now removed on Python 3.13. We are now using
`audioop-lts` (https://github.com/AbstractUmbra/audioop) to provide the same
functionality.
- Added support for more languages to ElevenLabs (Arabic, Croatian, Filipino,
Tamil) and PlayHT (Afrikans, Albanian, Amharic, Arabic, Bengali, Croatian, Tamil) and PlayHT (Afrikans, Albanian, Amharic, Arabic, Bengali, Croatian,
Galician, Hebrew, Mandarin, Serbian, Tagalog, Urdu, Xhosa). Galician, Hebrew, Mandarin, Serbian, Tagalog, Urdu, Xhosa).
### Changed ### Changed
- Changed: Room expiration (`exp`) in `DailyRoomProperties` is now optional - Room expiration (`exp`) in `DailyRoomProperties` is now optional (`None`) by
(None) by default instead of automatically setting a 5-minute expiration default instead of automatically setting a 5-minute expiration time. You must
time. You must explicitly set expiration time if desired. explicitly set expiration time if desired.
### Deprecated ### Deprecated
@@ -33,6 +38,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
only be pushed downstream after the audio generated from `TTSSpeakFrame` has only be pushed downstream after the audio generated from `TTSSpeakFrame` has
been spoken. been spoken.
## [0.0.51] - 2024-12-16
### Fixed
- Fixed an issue in websocket-based TTS services that was causing infinite
reconnections (Cartesia, ElevenLabs, PlayHT and LMNT).
## [0.0.50] - 2024-12-11 ## [0.0.50] - 2024-12-11
### Added ### Added

View File

@@ -55,19 +55,19 @@ pip install "pipecat-ai[option,...]"
Available options include: Available options include:
| Category | Services | Install Command Example | | Category | Services | Install Command Example |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/api-reference/services/stt/assemblyai), [Azure](https://docs.pipecat.ai/api-reference/services/stt/azure), [Deepgram](https://docs.pipecat.ai/api-reference/services/stt/deepgram), [Gladia](https://docs.pipecat.ai/api-reference/services/stt/gladia), [Whisper](https://docs.pipecat.ai/api-reference/services/stt/whisper) | `pip install "pipecat-ai[deepgram]"` | | Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) | `pip install "pipecat-ai[deepgram]"` |
| LLMs | [Anthropic](https://docs.pipecat.ai/api-reference/services/llm/anthropic), [Azure](https://docs.pipecat.ai/api-reference/services/llm/azure), [Fireworks AI](https://docs.pipecat.ai/api-reference/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/api-reference/services/llm/gemini), [Grok](https://docs.pipecat.ai/api-reference/services/llm/grok), [Groq](https://docs.pipecat.ai/api-reference/services/llm/groq), [NVIDIA NIM](https://docs.pipecat.ai/api-reference/services/llm/nim), [Ollama](https://docs.pipecat.ai/api-reference/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/api-reference/services/llm/openai), [Together AI](https://docs.pipecat.ai/api-reference/services/llm/together) | `pip install "pipecat-ai[openai]"` | | LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [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), [Together AI](https://docs.pipecat.ai/server/services/llm/together) | `pip install "pipecat-ai[openai]"` |
| Text-to-Speech | [AWS](https://docs.pipecat.ai/api-reference/services/tts/aws), [Azure](https://docs.pipecat.ai/api-reference/services/tts/azure), [Cartesia](https://docs.pipecat.ai/api-reference/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/api-reference/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/api-reference/services/tts/elevenlabs), [Google](https://docs.pipecat.ai/api-reference/services/tts/google), [LMNT](https://docs.pipecat.ai/api-reference/services/tts/lmnt), [OpenAI](https://docs.pipecat.ai/api-reference/services/tts/openai), [PlayHT](https://docs.pipecat.ai/api-reference/services/tts/playht), [Rime](https://docs.pipecat.ai/api-reference/services/tts/rime), [XTTS](https://docs.pipecat.ai/api-reference/services/tts/xtts) | `pip install "pipecat-ai[cartesia]"` | | 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), [Google](https://docs.pipecat.ai/server/services/tts/google), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) | `pip install "pipecat-ai[cartesia]"` |
| Speech-to-Speech | [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/api-reference/services/s2s/openai) | `pip install "pipecat-ai[openai]"` | | Speech-to-Speech | [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) | `pip install "pipecat-ai[openai]"` |
| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/api-reference/services/transport/daily), WebSocket, Local | `pip install "pipecat-ai[daily]"` | | Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), WebSocket, Local | `pip install "pipecat-ai[daily]"` |
| Video | [Tavus](https://docs.pipecat.ai/api-reference/services/video/tavus), [Simli](https://docs.pipecat.ai/api-reference/services/video/simli) | `pip install "pipecat-ai[tavus,simli]"` | | Video | [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | `pip install "pipecat-ai[tavus,simli]"` |
| Vision & Image | [Moondream](https://docs.pipecat.ai/api-reference/services/vision/moondream), [fal](https://docs.pipecat.ai/api-reference/services/image-generation/fal) | `pip install "pipecat-ai[moondream]"` | | Vision & Image | [Moondream](https://docs.pipecat.ai/server/services/vision/moondream), [fal](https://docs.pipecat.ai/server/services/image-generation/fal) | `pip install "pipecat-ai[moondream]"` |
| Audio Processing | [Silero VAD](https://docs.pipecat.ai/api-reference/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/api-reference/utilities/audio/krisp-filter), [Noisereduce](https://docs.pipecat.ai/api-reference/utilities/audio/noisereduce-filter) | `pip install "pipecat-ai[silero]"` | | Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Noisereduce](https://docs.pipecat.ai/server/utilities/audio/noisereduce-filter) | `pip install "pipecat-ai[silero]"` |
| Analytics & Metrics | [Canonical AI](https://docs.pipecat.ai/api-reference/services/analytics/canonical), [Sentry](https://docs.pipecat.ai/api-reference/services/analytics/sentry) | `pip install "pipecat-ai[canonical]"` | | Analytics & Metrics | [Canonical AI](https://docs.pipecat.ai/server/services/analytics/canonical), [Sentry](https://docs.pipecat.ai/server/services/analytics/sentry) | `pip install "pipecat-ai[canonical]"` |
📚 [View full services documentation →](https://docs.pipecat.ai/api-reference/services/supported-services) 📚 [View full services documentation →](https://docs.pipecat.ai/server/services/supported-services)
## Code examples ## Code examples

View File

@@ -1,8 +1,9 @@
build~=1.2.1 build~=1.2.2
grpcio-tools~=1.65.4 grpcio-tools~=1.68.1
pip-tools~=7.4.1 pip-tools~=7.4.1
pyright~=1.1.376 pyright~=1.1.390
pytest~=8.3.2 pytest~=8.3.4
ruff~=0.6.7 ruff~=0.8.3
setuptools~=72.2.0 setuptools~=75.6.0
setuptools_scm~=8.1.0 setuptools_scm~=8.1.0
python-dotenv~=1.0.1

View File

@@ -65,7 +65,7 @@ async def main():
) )
llm = NimLLMService( llm = NimLLMService(
api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct" api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.3-70b-instruct"
) )
# Register a function_name of None to get all functions # Register a function_name of None to get all functions
# sent to the same callback with an additional function_name parameter. # sent to the same callback with an additional function_name parameter.
@@ -76,18 +76,18 @@ async def main():
type="function", type="function",
function={ function={
"name": "get_current_weather", "name": "get_current_weather",
"description": "Get the current weather", "description": "Returns the current weather at a location, if one is specified, and defaults to the user's location.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"location": { "location": {
"type": "string", "type": "string",
"description": "The city and state, e.g. San Francisco, CA", "description": "The location to find the weather of, or if not provided, it's the default location.",
}, },
"format": { "format": {
"type": "string", "type": "string",
"enum": ["celsius", "fahrenheit"], "enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Infer this from the users location.", "description": "Whether to use SI or USCS units (celsius or fahrenheit).",
}, },
}, },
"required": ["location", "format"], "required": ["location", "format"],

View File

@@ -24,12 +24,12 @@ This repository demonstrates a simple AI chatbot with real-time audio/video inte
2. **JavaScript** 2. **JavaScript**
- Basic implementation using [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/reference/js/introduction) - Basic implementation using [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/js/introduction)
- No framework dependencies - No framework dependencies
- Good for learning the fundamentals - Good for learning the fundamentals
3. **React** 3. **React**
- Basic impelmentation using [Pipecat React SDK](https://docs.pipecat.ai/client/reference/react/introduction) - Basic impelmentation using [Pipecat React SDK](https://docs.pipecat.ai/client/react/introduction)
- Demonstrates the basic client principles with Pipecat React - Demonstrates the basic client principles with Pipecat React
## Quick Start ## Quick Start

View File

@@ -1,6 +1,6 @@
# JavaScript Implementation # JavaScript Implementation
Basic implementation using the [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/reference/js/introduction). Basic implementation using the [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/js/introduction).
## Setup ## Setup

View File

@@ -1,6 +1,6 @@
# React Implementation # React Implementation
Basic implementation using the [Pipecat React SDK](https://docs.pipecat.ai/client/reference/react/introduction). Basic implementation using the [Pipecat React SDK](https://docs.pipecat.ai/client/react/introduction).
## Setup ## Setup

View File

@@ -1,4 +1,4 @@
DAILY_SAMPLE_ROOM_URL= # Follow instructions here and put your https://YOURDOMAIN.daily.co/YOURROOM (Instructions: https://docs.pipecat.ai/quickstart#preparing-your-environment) DAILY_SAMPLE_ROOM_URL= # Follow instructions here and put your https://YOURDOMAIN.daily.co/YOURROOM (Instructions: https://docs.pipecat.ai/getting-started/installation)
DAILY_API_KEY= # Create here: https://dashboard.daily.co/developers DAILY_API_KEY= # Create here: https://dashboard.daily.co/developers
OPENAI_API_KEY= # Create here: https://platform.openai.com/docs/overview OPENAI_API_KEY= # Create here: https://platform.openai.com/docs/overview
CARTESIA_API_KEY= # Create here: https://play.cartesia.ai/console CARTESIA_API_KEY= # Create here: https://play.cartesia.ai/console

View File

@@ -21,14 +21,17 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"aiohttp~=3.11.10", "aiohttp~=3.11.10",
"loguru~=0.7.2", "audioop-lts~=0.2.1; python_version>='3.13'",
"loguru~=0.7.3",
"Markdown~=3.7", "Markdown~=3.7",
"numpy~=1.26.4", "numpy~=2.1.3",
"Pillow~=10.4.0", "numba~=0.61.0rc1",
"Pillow~=11.0.0",
"protobuf~=5.29.1", "protobuf~=5.29.1",
"pydantic~=2.8.2", "pydantic~=2.10.3",
"pyloudnorm~=0.1.1", "pyloudnorm~=0.1.1",
"resampy~=0.4.3", "resampy~=0.4.3",
"tenacity~=9.0.0"
] ]
[project.urls] [project.urls]
@@ -45,7 +48,6 @@ cartesia = [ "cartesia~=1.0.13", "websockets~=13.1" ]
daily = [ "daily-python~=0.13.0" ] daily = [ "daily-python~=0.13.0" ]
deepgram = [ "deepgram-sdk~=3.7.7" ] deepgram = [ "deepgram-sdk~=3.7.7" ]
elevenlabs = [ "websockets~=13.1" ] elevenlabs = [ "websockets~=13.1" ]
examples = [ "python-dotenv~=1.0.1", "flask~=3.0.3", "flask_cors~=4.0.1" ]
fal = [ "fal-client~=0.4.1" ] fal = [ "fal-client~=0.4.1" ]
gladia = [ "websockets~=13.1" ] gladia = [ "websockets~=13.1" ]
google = [ "google-generativeai~=0.8.3", "google-cloud-texttospeech~=2.21.1" ] google = [ "google-generativeai~=0.8.3", "google-cloud-texttospeech~=2.21.1" ]
@@ -55,15 +57,15 @@ gstreamer = [ "pygobject~=3.48.2" ]
fireworks = [ "openai~=1.57.2" ] fireworks = [ "openai~=1.57.2" ]
krisp = [ "pipecat-ai-krisp~=0.3.0" ] krisp = [ "pipecat-ai-krisp~=0.3.0" ]
langchain = [ "langchain~=0.2.14", "langchain-community~=0.2.12", "langchain-openai~=0.1.20" ] langchain = [ "langchain~=0.2.14", "langchain-community~=0.2.12", "langchain-openai~=0.1.20" ]
livekit = [ "livekit~=0.18.2", "livekit-api~=0.8.0", "tenacity~=8.5.0" ] livekit = [ "livekit~=0.17.5", "livekit-api~=0.7.1" ]
lmnt = [ "lmnt~=1.1.4" ] lmnt = [ "lmnt~=1.1.4" ]
local = [ "pyaudio~=0.2.14" ] local = [ "pyaudio~=0.2.14" ]
moondream = [ "einops~=0.8.0", "timm~=1.0.8", "transformers~=4.44.0" ] moondream = [ "einops~=0.8.0", "timm~=1.0.8", "transformers~=4.44.0" ]
nim = [ "openai~=1.57.2" ] nim = [ "openai~=1.57.2" ]
noisereduce = [ "noisereduce~=3.0.3" ] noisereduce = [ "noisereduce~=3.0.3" ]
openai = [ "openai~=1.57.2", "websockets~=13.1", "python-deepcompare~=1.0.1" ] openai = [ "openai~=1.57.2", "websockets~=13.1", "python-deepcompare~=1.0.1" ]
openpipe = [ "openpipe~=4.38.0" ] openpipe = [ "openpipe~=4.40.0" ]
playht = [ "pyht~=0.1.8", "websockets~=13.1" ] playht = [ "pyht~=0.1.9", "websockets~=13.1" ]
riva = [ "nvidia-riva-client~=2.17.0" ] riva = [ "nvidia-riva-client~=2.17.0" ]
silero = [ "onnxruntime~=1.20.1" ] silero = [ "onnxruntime~=1.20.1" ]
simli = [ "simli-ai~=0.1.7"] simli = [ "simli-ai~=0.1.7"]

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import time import time
from pipecat.frames.frames import MetricsFrame from pipecat.frames.frames import MetricsFrame

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import time import time
from loguru import logger from loguru import logger
@@ -29,8 +35,9 @@ class SentryMetrics(FrameProcessorMetrics):
description=f"TTFB for {self._processor_name()}", description=f"TTFB for {self._processor_name()}",
start_timestamp=self._start_ttfb_time, start_timestamp=self._start_ttfb_time,
) )
logger.debug(f"Sentry Span ID: {self._ttfb_metrics_span.span_id} Description: { logger.debug(
self._ttfb_metrics_span.description} started.") f"Sentry Span ID: {self._ttfb_metrics_span.span_id} Description: {self._ttfb_metrics_span.description} started."
)
self._should_report_ttfb = not report_only_initial_ttfb self._should_report_ttfb = not report_only_initial_ttfb
async def stop_ttfb_metrics(self): async def stop_ttfb_metrics(self):
@@ -46,8 +53,9 @@ class SentryMetrics(FrameProcessorMetrics):
description=f"Processing for {self._processor_name()}", description=f"Processing for {self._processor_name()}",
start_timestamp=self._start_processing_time, start_timestamp=self._start_processing_time,
) )
logger.debug(f"Sentry Span ID: {self._processing_metrics_span.span_id} Description: { logger.debug(
self._processing_metrics_span.description} started.") f"Sentry Span ID: {self._processing_metrics_span.span_id} Description: {self._processing_metrics_span.description} started."
)
async def stop_processing_metrics(self): async def stop_processing_metrics(self):
stop_time = time.time() stop_time = time.time()

View File

@@ -12,6 +12,8 @@ from typing import AsyncGenerator, List, Optional, Union
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt, wait_exponential
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
@@ -239,52 +241,64 @@ class CartesiaTTSService(WordTTSService):
msg = self._build_msg(text="", continue_transcript=False) msg = self._build_msg(text="", continue_transcript=False)
await self._websocket.send(msg) await self._websocket.send(msg)
async def _receive_messages(self):
async for message in self._get_websocket():
msg = json.loads(message)
if not msg or msg["context_id"] != self._context_id:
continue
if msg["type"] == "done":
await self.stop_ttfb_metrics()
# Unset _context_id but not the _context_id_start_timestamp
# because we are likely still playing out audio and need the
# timestamp to set send context frames.
self._context_id = None
await self.add_word_timestamps(
[("TTSStoppedFrame", 0), ("LLMFullResponseEndFrame", 0), ("Reset", 0)]
)
elif msg["type"] == "timestamps":
await self.add_word_timestamps(
list(zip(msg["word_timestamps"]["words"], msg["word_timestamps"]["start"]))
)
elif msg["type"] == "chunk":
await self.stop_ttfb_metrics()
self.start_word_timestamps()
frame = TTSAudioRawFrame(
audio=base64.b64decode(msg["data"]),
sample_rate=self._settings["output_format"]["sample_rate"],
num_channels=1,
)
await self.push_frame(frame)
elif msg["type"] == "error":
logger.error(f"{self} error: {msg}")
await self.push_frame(TTSStoppedFrame())
await self.stop_all_metrics()
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
else:
logger.error(f"{self} error, unknown message type: {msg}")
async def _reconnect_websocket(self, retry_state: RetryCallState):
logger.warning(f"{self} reconnecting (attempt: {retry_state.attempt_number})")
await self._disconnect_websocket()
await self._connect_websocket()
async def _receive_task_handler(self): async def _receive_task_handler(self):
while True: while True:
try: try:
async for message in self._get_websocket(): async for attempt in AsyncRetrying(
msg = json.loads(message) stop=stop_after_attempt(3),
if not msg or msg["context_id"] != self._context_id: wait=wait_exponential(multiplier=1, min=4, max=10),
continue before_sleep=self._reconnect_websocket,
if msg["type"] == "done": reraise=True,
await self.stop_ttfb_metrics() ):
# Unset _context_id but not the _context_id_start_timestamp with attempt:
# because we are likely still playing out audio and need the await self._receive_messages()
# timestamp to set send context frames.
self._context_id = None
await self.add_word_timestamps(
[("TTSStoppedFrame", 0), ("LLMFullResponseEndFrame", 0), ("Reset", 0)]
)
elif msg["type"] == "timestamps":
await self.add_word_timestamps(
list(
zip(
msg["word_timestamps"]["words"], msg["word_timestamps"]["start"]
)
)
)
elif msg["type"] == "chunk":
await self.stop_ttfb_metrics()
self.start_word_timestamps()
frame = TTSAudioRawFrame(
audio=base64.b64decode(msg["data"]),
sample_rate=self._settings["output_format"]["sample_rate"],
num_channels=1,
)
await self.push_frame(frame)
elif msg["type"] == "error":
logger.error(f"{self} error: {msg}")
await self.push_frame(TTSStoppedFrame())
await self.stop_all_metrics()
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
else:
logger.error(f"{self} error, unknown message type: {msg}")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"{self} exception: {e}") message = f"{self} error receiving messages: {e}"
await self._disconnect_websocket() logger.error(message)
await self._connect_websocket() await self.push_error(ErrorFrame(message, fatal=True))
break
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)

View File

@@ -11,11 +11,13 @@ from typing import Any, AsyncGenerator, Dict, List, Literal, Mapping, Optional,
from loguru import logger from loguru import logger
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt, wait_exponential
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame,
Frame, Frame,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
StartFrame, StartFrame,
@@ -352,28 +354,44 @@ class ElevenLabsTTSService(WordTTSService):
except Exception as e: except Exception as e:
logger.error(f"{self} error closing websocket: {e}") logger.error(f"{self} error closing websocket: {e}")
async def _receive_messages(self):
async for message in self._websocket:
msg = json.loads(message)
if msg.get("audio"):
await self.stop_ttfb_metrics()
self.start_word_timestamps()
audio = base64.b64decode(msg["audio"])
frame = TTSAudioRawFrame(audio, self._settings["sample_rate"], 1)
await self.push_frame(frame)
if msg.get("alignment"):
word_times = calculate_word_times(msg["alignment"], self._cumulative_time)
await self.add_word_timestamps(word_times)
self._cumulative_time = word_times[-1][1]
async def _reconnect_websocket(self, retry_state: RetryCallState):
logger.warning(f"{self} reconnecting (attempt: {retry_state.attempt_number})")
await self._disconnect_websocket()
await self._connect_websocket()
async def _receive_task_handler(self): async def _receive_task_handler(self):
while True: while True:
try: try:
async for message in self._websocket: async for attempt in AsyncRetrying(
msg = json.loads(message) stop=stop_after_attempt(3),
if msg.get("audio"): wait=wait_exponential(multiplier=1, min=4, max=10),
await self.stop_ttfb_metrics() before_sleep=self._reconnect_websocket,
self.start_word_timestamps() reraise=True,
):
audio = base64.b64decode(msg["audio"]) with attempt:
frame = TTSAudioRawFrame(audio, self._settings["sample_rate"], 1) await self._receive_messages()
await self.push_frame(frame)
if msg.get("alignment"):
word_times = calculate_word_times(msg["alignment"], self._cumulative_time)
await self.add_word_timestamps(word_times)
self._cumulative_time = word_times[-1][1]
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"{self} exception: {e}") message = f"{self} error receiving messages: {e}"
await self._disconnect_websocket() logger.error(message)
await self._connect_websocket() await self.push_error(ErrorFrame(message, fatal=True))
break
async def _keepalive_task_handler(self): async def _keepalive_task_handler(self):
while True: while True:

View File

@@ -5,11 +5,102 @@
# #
import json
from dataclasses import dataclass
from loguru import logger from loguru import logger
from pipecat.metrics.metrics import LLMTokenUsage from pipecat.metrics.metrics import LLMTokenUsage
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.aggregators.openai_llm_context import (
from pipecat.services.openai import OpenAILLMService OpenAILLMContext,
OpenAILLMContextFrame,
)
from pipecat.services.openai import (
OpenAIAssistantContextAggregator,
OpenAILLMService,
OpenAIUserContextAggregator,
)
class GrokAssistantContextAggregator(OpenAIAssistantContextAggregator):
"""Custom assistant context aggregator for Grok that handles empty content requirement."""
async def _push_aggregation(self):
if not (
self._aggregation or self._function_call_result or self._pending_image_frame_message
):
return
run_llm = False
aggregation = self._aggregation
self._reset()
try:
if self._function_call_result:
frame = self._function_call_result
self._function_call_result = None
if frame.result:
# Grok requires an empty content field for function calls
self._context.add_message(
{
"role": "assistant",
"content": "", # Required by Grok
"tool_calls": [
{
"id": frame.tool_call_id,
"function": {
"name": frame.function_name,
"arguments": json.dumps(frame.arguments),
},
"type": "function",
}
],
}
)
self._context.add_message(
{
"role": "tool",
"content": json.dumps(frame.result),
"tool_call_id": frame.tool_call_id,
}
)
# Only run the LLM if there are no more function calls in progress.
run_llm = not bool(self._function_calls_in_progress)
else:
self._context.add_message({"role": "assistant", "content": aggregation})
if self._pending_image_frame_message:
frame = self._pending_image_frame_message
self._pending_image_frame_message = None
self._context.add_image_frame_message(
format=frame.user_image_raw_frame.format,
size=frame.user_image_raw_frame.size,
image=frame.user_image_raw_frame.image,
text=frame.text,
)
run_llm = True
if run_llm:
await self._user_context_aggregator.push_context_frame()
frame = OpenAILLMContextFrame(self._context)
await self.push_frame(frame)
except Exception as e:
logger.error(f"Error processing frame: {e}")
@dataclass
class GrokContextAggregatorPair:
_user: "OpenAIUserContextAggregator"
_assistant: "GrokAssistantContextAggregator"
def user(self) -> "OpenAIUserContextAggregator":
return self._user
def assistant(self) -> "GrokAssistantContextAggregator":
return self._assistant
class GrokLLMService(OpenAILLMService): class GrokLLMService(OpenAILLMService):
@@ -101,3 +192,13 @@ class GrokLLMService(OpenAILLMService):
# Update completion tokens count if it has increased # Update completion tokens count if it has increased
if tokens.completion_tokens > self._completion_tokens: if tokens.completion_tokens > self._completion_tokens:
self._completion_tokens = tokens.completion_tokens self._completion_tokens = tokens.completion_tokens
@staticmethod
def create_context_aggregator(
context: OpenAILLMContext, *, assistant_expect_stripped_words: bool = True
) -> GrokContextAggregatorPair:
user = OpenAIUserContextAggregator(context)
assistant = GrokAssistantContextAggregator(
user, expect_stripped_words=assistant_expect_stripped_words
)
return GrokContextAggregatorPair(_user=user, _assistant=assistant)

View File

@@ -8,6 +8,7 @@ import asyncio
from typing import AsyncGenerator from typing import AsyncGenerator
from loguru import logger from loguru import logger
from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt, wait_exponential
from pipecat.frames.frames import ( from pipecat.frames.frames import (
CancelFrame, CancelFrame,
@@ -159,31 +160,47 @@ class LmntTTSService(TTSService):
except Exception as e: except Exception as e:
logger.error(f"{self} error closing connection: {e}") logger.error(f"{self} error closing connection: {e}")
async def _receive_messages(self):
async for msg in self._connection:
if "error" in msg:
logger.error(f'{self} error: {msg["error"]}')
await self.push_frame(TTSStoppedFrame())
await self.stop_all_metrics()
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
elif "audio" in msg:
await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(
audio=msg["audio"],
sample_rate=self._settings["output_format"]["sample_rate"],
num_channels=1,
)
await self.push_frame(frame)
else:
logger.error(f"{self}: LMNT error, unknown message type: {msg}")
async def _reconnect_websocket(self, retry_state: RetryCallState):
logger.warning(f"{self} reconnecting (attempt: {retry_state.attempt_number})")
await self._disconnect_lmnt()
await self._connect_lmnt()
async def _receive_task_handler(self): async def _receive_task_handler(self):
while True: while True:
try: try:
async for msg in self._connection: async for attempt in AsyncRetrying(
if "error" in msg: stop=stop_after_attempt(3),
logger.error(f'{self} error: {msg["error"]}') wait=wait_exponential(multiplier=1, min=4, max=10),
await self.push_frame(TTSStoppedFrame()) before_sleep=self._reconnect_websocket,
await self.stop_all_metrics() reraise=True,
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}')) ):
elif "audio" in msg: with attempt:
await self.stop_ttfb_metrics() await self._receive_messages()
frame = TTSAudioRawFrame(
audio=msg["audio"],
sample_rate=self._settings["output_format"]["sample_rate"],
num_channels=1,
)
await self.push_frame(frame)
else:
logger.error(f"{self}: LMNT error, unknown message type: {msg}")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"{self} exception: {e}") message = f"{self} error receiving messages: {e}"
await self._disconnect_lmnt() logger.error(message)
await self._connect_lmnt() await self.push_error(ErrorFrame(message, fatal=True))
break
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
logger.debug(f"Generating TTS: [{text}]") logger.debug(f"Generating TTS: [{text}]")

View File

@@ -559,7 +559,6 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator):
self._context.add_message( self._context.add_message(
{ {
"role": "assistant", "role": "assistant",
"content": "", # content field required for Grok function calling
"tool_calls": [ "tool_calls": [
{ {
"id": frame.tool_call_id, "id": frame.tool_call_id,

View File

@@ -15,6 +15,7 @@ import aiohttp
import websockets import websockets
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt, wait_exponential
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
@@ -234,35 +235,51 @@ class PlayHTTTSService(TTSService):
await self.stop_all_metrics() await self.stop_all_metrics()
self._request_id = None self._request_id = None
async def _receive_messages(self):
async for message in self._get_websocket():
if isinstance(message, bytes):
# Skip the WAV header message
if message.startswith(b"RIFF"):
continue
await self.stop_ttfb_metrics()
frame = TTSAudioRawFrame(message, self._settings["sample_rate"], 1)
await self.push_frame(frame)
else:
logger.debug(f"Received text message: {message}")
try:
msg = json.loads(message)
if "request_id" in msg and msg["request_id"] == self._request_id:
await self.push_frame(TTSStoppedFrame())
self._request_id = None
elif "error" in msg:
logger.error(f"{self} error: {msg}")
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
except json.JSONDecodeError:
logger.error(f"Invalid JSON message: {message}")
async def _reconnect_websocket(self, retry_state: RetryCallState):
logger.warning(f"{self} reconnecting (attempt: {retry_state.attempt_number})")
await self._disconnect_websocket()
await self._connect_websocket()
async def _receive_task_handler(self): async def _receive_task_handler(self):
while True: while True:
try: try:
async for message in self._get_websocket(): async for attempt in AsyncRetrying(
if isinstance(message, bytes): stop=stop_after_attempt(3),
# Skip the WAV header message wait=wait_exponential(multiplier=1, min=4, max=10),
if message.startswith(b"RIFF"): before_sleep=self._reconnect_websocket,
continue reraise=True,
await self.stop_ttfb_metrics() ):
frame = TTSAudioRawFrame(message, self._settings["sample_rate"], 1) with attempt:
await self.push_frame(frame) await self._receive_messages()
else:
logger.debug(f"Received text message: {message}")
try:
msg = json.loads(message)
if "request_id" in msg and msg["request_id"] == self._request_id:
await self.push_frame(TTSStoppedFrame())
self._request_id = None
elif "error" in msg:
logger.error(f"{self} error: {msg}")
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
except json.JSONDecodeError:
logger.error(f"Invalid JSON message: {message}")
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"{self} exception in receive task: {e}") message = f"{self} error receiving messages: {e}"
await self._disconnect_websocket() logger.error(message)
await self._connect_websocket() await self.push_error(ErrorFrame(message, fatal=True))
break
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)