Files
pipecat/src/pipecat/services/resembleai/tts.py
Mark Backman 08fe9157cc Widen run_stt/run_tts return type to AsyncGenerator[Frame | None, None]
The push-based STT/TTS implementations send audio/text over a socket and
receive results via a separate receive task, so there is nothing to
yield inline. They yield `None` by design. The previous declaration of
`AsyncGenerator[Frame, None]` disagreed with that, while the consumer
(`AIService.process_generator`) already accepted `Frame | None`. Widen
the producer side (abstract base and every subclass) so the type honestly
describes the contract.

Pure annotation change; no runtime behavior difference.
2026-04-22 11:01:50 -04:00

472 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Resemble AI text-to-speech service implementations."""
import base64
import json
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from loguru import logger
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
ErrorFrame,
Frame,
StartFrame,
TTSAudioRawFrame,
TTSStartedFrame,
TTSStoppedFrame,
)
from pipecat.services.settings import TTSSettings
from pipecat.services.tts_service import WebsocketTTSService
from pipecat.utils.tracing.service_decorators import traced_tts
try:
from websockets.asyncio.client import connect as websocket_connect
from websockets.protocol import State
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use Resemble AI, you need to `pip install pipecat-ai[resembleai]`.")
raise Exception(f"Missing module: {e}")
@dataclass
class ResembleAITTSSettings(TTSSettings):
"""Settings for ResembleAITTSService."""
pass
class ResembleAITTSService(WebsocketTTSService):
"""Resemble AI TTS service with WebSocket streaming and word timestamps.
Provides text-to-speech using Resemble AI's streaming WebSocket API.
Supports word-level timestamps and audio context management for handling
multiple simultaneous synthesis requests with proper interruption support.
"""
Settings = ResembleAITTSSettings
_settings: Settings
def __init__(
self,
*,
api_key: str,
voice_id: str | None = None,
url: str = "wss://websocket.cluster.resemble.ai/stream",
precision: str | None = "PCM_16",
output_format: str | None = "wav",
sample_rate: int | None = 22050,
settings: Settings | None = None,
**kwargs,
):
"""Initialize the Resemble AI TTS service.
Args:
api_key: Resemble AI API key for authentication.
voice_id: Voice UUID to use for synthesis.
.. deprecated:: 0.0.105
Use ``settings=ResembleAITTSService.Settings(voice=...)`` instead.
url: WebSocket URL for Resemble AI TTS API.
precision: PCM bit depth (PCM_32, PCM_24, PCM_16, or MULAW).
output_format: Audio format (wav or mp3).
sample_rate: Audio sample rate (8000, 16000, 22050, 32000, or 44100). Defaults to 22050.
settings: Runtime-updatable settings. When provided alongside deprecated
parameters, ``settings`` values take precedence.
**kwargs: Additional arguments passed to the parent service.
"""
# 1. Initialize default_settings with hardcoded defaults
default_settings = self.Settings(
model=None,
voice=None,
language=None,
)
# 2. Apply direct init arg overrides (deprecated)
if voice_id is not None:
self._warn_init_param_moved_to_settings("voice_id", "voice")
default_settings.voice = voice_id
# 3. (No step 3, as there's no params object to apply)
# 4. Apply settings delta (canonical API, always wins)
if settings is not None:
default_settings.apply_update(settings)
super().__init__(
sample_rate=sample_rate,
reuse_context_id_within_turn=False,
settings=default_settings,
**kwargs,
)
self._api_key = api_key
self._url = url
# Init-only audio format config (not runtime-updatable).
self._precision = precision or "PCM_16"
self._output_format = output_format or "wav"
self._resemble_sample_rate = 0 # Set in start()
self._websocket = None
self._request_id_counter = 0
self._receive_task = None
# Map request_id to context_id for tracking TTS requests
self._request_id_to_context: dict[int, str] = {}
# Per-request audio buffers to handle concurrent TTS requests
# ResembleAI may send odd-length data even for PCM_16, so buffering helps us
# create properly aligned frames while maintaining smooth audio output
self._audio_buffers: dict[str, bytearray] = {}
self._buffer_threshold_bytes = 2208
# Jitter buffer: accumulate audio before starting playback to absorb network latency
# ResembleAI sends audio in bursts with 300-450ms gaps between them
# We need to buffer enough to cover these gaps before starting playback
self._jitter_buffer_bytes = 44100 # ~1000ms at 22050Hz to handle 400ms+ network gaps
self._playback_started: dict[str, bool] = {} # Track if we've started playback per request
def can_generate_metrics(self) -> bool:
"""Check if this service can generate processing metrics.
Returns:
True, as Resemble AI service supports metrics generation.
"""
return True
def _build_msg(self, text: str = "") -> str:
"""Build a JSON message for the Resemble AI WebSocket API.
Args:
text: The text or SSML to synthesize.
Returns:
JSON string containing the request payload.
"""
msg = {
"voice_uuid": self._settings.voice,
"data": text,
"binary_response": False, # Use JSON frames to get timestamps
"request_id": self._request_id_counter, # ResembleAI only accepts number
"output_format": self._output_format,
"sample_rate": self._resemble_sample_rate,
"precision": self._precision,
"no_audio_header": True,
}
self._request_id_counter += 1
return json.dumps(msg)
async def start(self, frame: StartFrame):
"""Start the Resemble AI TTS service.
Args:
frame: The start frame containing initialization parameters.
"""
await super().start(frame)
self._resemble_sample_rate = self.sample_rate
await self._connect()
async def stop(self, frame: EndFrame):
"""Stop the Resemble AI TTS service.
Args:
frame: The end frame.
"""
await super().stop(frame)
await self._disconnect()
async def cancel(self, frame: CancelFrame):
"""Cancel the Resemble AI TTS service.
Args:
frame: The cancel frame.
"""
await super().cancel(frame)
await self._disconnect()
async def _connect(self):
"""Connect to the Resemble AI WebSocket."""
await self._connect_websocket()
if self._websocket and not self._receive_task:
self._receive_task = self.create_task(self._receive_task_handler(self._report_error))
async def _disconnect(self):
"""Disconnect from the Resemble AI WebSocket."""
if self._receive_task:
await self.cancel_task(self._receive_task)
self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self):
"""Establish WebSocket connection to Resemble AI."""
try:
if self._websocket and self._websocket.state is State.OPEN:
return
logger.debug("Connecting to Resemble AI TTS")
headers = {"Authorization": f"Bearer {self._api_key}"}
self._websocket = await websocket_connect(self._url, additional_headers=headers)
await self._call_event_handler("on_connected")
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
self._websocket = None
await self._call_event_handler("on_connection_error", f"{e}")
async def _disconnect_websocket(self):
"""Close WebSocket connection to Resemble AI."""
try:
await self.stop_all_metrics()
if self._websocket:
logger.debug("Disconnecting from Resemble AI")
# ResembleAI doesn't send disconnect acknowledgement, set close_timeout to 0
self._websocket.close_timeout = 0
await self._websocket.close()
except Exception as e:
await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e)
finally:
self._websocket = None
self._audio_buffers.clear()
self._playback_started.clear()
self._request_id_to_context.clear()
await self._call_event_handler("on_disconnected")
def _get_websocket(self):
"""Get the current WebSocket connection.
Returns:
The active WebSocket connection.
Raises:
Exception: If websocket is not connected.
"""
if self._websocket:
return self._websocket
raise Exception("Websocket not connected")
async def on_audio_context_interrupted(self, context_id: str):
"""Stop metrics when the bot is interrupted."""
await self.stop_all_metrics()
await super().on_audio_context_interrupted(context_id)
async def on_audio_context_completed(self, context_id: str):
"""Stop metrics after the Resemble AI context finishes playing.
No close message is needed: Resemble AI signals completion with an
``audio_end`` message (handled in ``_process_messages``), after which
the server-side context is already closed.
"""
await super().on_audio_context_completed(context_id)
async def flush_audio(self, context_id: str | None = None):
"""Flush any pending audio and finalize the current context."""
logger.trace(f"{self}: flushing audio")
# For Resemble AI, we just wait for the audio_end message
# which is handled in _process_messages
async def _process_messages(self):
"""Process incoming WebSocket messages from Resemble AI."""
async for message in self._get_websocket():
try:
msg = json.loads(message)
except json.JSONDecodeError:
await self.push_error(error_msg=f"Received invalid JSON: {message}")
continue
if not msg:
continue
msg_type = msg.get("type")
request_id = msg.get("request_id")
# Convert request_id to string for audio context tracking
context_id = self._request_id_to_context.get(request_id, str(request_id))
# Check if this message belongs to a valid audio context
if not self.audio_context_available(context_id):
continue
if msg_type == "audio":
# Decode base64 audio content
audio_content = msg.get("audio_content", "")
if not audio_content:
continue
audio_bytes = base64.b64decode(audio_content)
if len(audio_bytes) == 0:
continue
# Get or create buffer for this request
if context_id not in self._audio_buffers:
self._audio_buffers[context_id] = bytearray()
self._playback_started[context_id] = False
buffer = self._audio_buffers[context_id]
# Add to buffer
buffer.extend(audio_bytes)
# Wait for jitter buffer to fill before starting playback
# This absorbs network latency gaps (ResembleAI sends in bursts)
if not self._playback_started.get(context_id, False):
if len(buffer) < self._jitter_buffer_bytes:
continue
self._playback_started[context_id] = True
# Send complete (even-byte) chunks for PCM_16 alignment
while len(buffer) >= self._buffer_threshold_bytes:
chunk_size = self._buffer_threshold_bytes
if chunk_size % 2 != 0:
chunk_size -= 1
chunk_to_send = bytes(buffer[:chunk_size])
self._audio_buffers[context_id] = buffer[chunk_size:]
buffer = self._audio_buffers[context_id]
if len(chunk_to_send) == 0:
continue
frame = TTSAudioRawFrame(
audio=chunk_to_send,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
await self.append_to_audio_context(context_id, frame)
# Process timestamps if available
timestamps = msg.get("audio_timestamps", {})
if timestamps:
graph_chars = timestamps.get("graph_chars", [])
graph_times = timestamps.get("graph_times", [])
# Convert graph_times (start, end pairs) to word timestamps
word_times = []
for char, times in zip(graph_chars, graph_times):
if times and len(times) >= 2:
start_time = times[0]
word_times.append((char, start_time))
if word_times:
await self.add_word_timestamps(word_times, context_id)
elif msg_type == "audio_end":
await self.stop_ttfb_metrics()
# Flush remaining buffer, ensuring even length for PCM_16
buffer = self._audio_buffers.get(context_id, bytearray())
if buffer:
remaining = bytes(buffer)
# PCM_16 requires even number of bytes
if len(remaining) % 2 != 0:
remaining = remaining[:-1]
if remaining:
frame = TTSAudioRawFrame(
audio=remaining,
sample_rate=self.sample_rate,
num_channels=1,
context_id=context_id,
)
await self.append_to_audio_context(context_id, frame)
# Clean up buffer and playback tracking for this request
if context_id in self._audio_buffers:
del self._audio_buffers[context_id]
if context_id in self._playback_started:
del self._playback_started[context_id]
# Clean up request_id mapping
if request_id in self._request_id_to_context:
del self._request_id_to_context[request_id]
await self.append_to_audio_context(
context_id, TTSStoppedFrame(context_id=context_id)
)
await self.remove_audio_context(context_id)
elif msg_type == "error":
error_name = msg.get("error_name", "Unknown")
error_msg = msg.get("message", "Unknown error")
status_code = msg.get("status_code", 0)
await self.push_error(
error_msg=f"Error: {error_name} (status {status_code}): {error_msg}"
)
# Clean up buffer and playback tracking for this request
if context_id in self._audio_buffers:
del self._audio_buffers[context_id]
if context_id in self._playback_started:
del self._playback_started[context_id]
await self.push_frame(TTSStoppedFrame(context_id=context_id))
await self.stop_all_metrics()
await self.push_error(ErrorFrame(error=f"{self} error: {error_name} - {error_msg}"))
# Check if this is an unrecoverable error (connection-level failure)
if status_code in [401, 403]:
# Close and reconnect for auth errors
await self._disconnect_websocket()
await self._connect_websocket()
else:
logger.warning(f"{self} unknown message type: {msg_type}")
async def _receive_messages(self):
"""Main loop for receiving messages from Resemble AI."""
while True:
try:
await self._process_messages()
except Exception as e:
await self.push_error(error_msg=f"Error in receive loop: {e}", exception=e)
# Try to reconnect
logger.debug(f"{self} Resemble AI connection lost, reconnecting")
await self._connect_websocket()
@traced_tts
async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame | None, None]:
"""Generate speech from text using Resemble AI's streaming API.
Args:
text: The text to synthesize into speech.
context_id: Unique identifier for this TTS context.
Yields:
Frame: Audio frames containing the synthesized speech.
"""
logger.debug(f"{self}: Generating TTS [{text}]")
try:
if not self._websocket or self._websocket.state is State.CLOSED:
await self._connect()
if not self.audio_context_available(context_id):
await self.create_audio_context(context_id)
await self.start_ttfb_metrics()
yield TTSStartedFrame(context_id=context_id)
# Map request_id to context_id for tracking
self._request_id_to_context[self._request_id_counter] = context_id
msg = self._build_msg(text=text)
try:
await self._get_websocket().send(msg)
await self.start_tts_usage_metrics(text)
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")
yield TTSStoppedFrame(context_id=context_id)
await self._disconnect()
await self._connect()
return
yield None
except Exception as e:
yield ErrorFrame(error=f"Unknown error occurred: {e}")