Merge pull request #1272 from pipecat-ai/aleix/tts-websocket-interruptions

services: fix some TTS websocket service interruption handling
This commit is contained in:
Aleix Conchillo Flaqué
2025-02-24 14:54:41 -08:00
committed by GitHub
9 changed files with 150 additions and 60 deletions

View File

@@ -42,6 +42,10 @@ stt = DeepgramSTTService(..., live_options=LiveOptions(model="nova-2-general"))
### Fixed ### Fixed
- Fixed a `ElevenLabsTTSService`, `FishAudioTTSService`, `LMNTTTSService` and
`PlayHTTTSService` issue that was resulting in audio requested before an
interruption being played after an interruption.
- Fixed `match_endofsentence` support for ellipses. - Fixed `match_endofsentence` support for ellipses.
- Fixed an issue that would cause undesired interruptions via - Fixed an issue that would cause undesired interruptions via

View File

@@ -15,6 +15,7 @@ from loguru import logger
from pipecat.audio.utils import calculate_audio_volume, exp_smoothing from pipecat.audio.utils import calculate_audio_volume, exp_smoothing
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame, AudioRawFrame,
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
@@ -40,6 +41,7 @@ from pipecat.frames.frames import (
from pipecat.metrics.metrics import MetricsData from pipecat.metrics.metrics import MetricsData
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
from pipecat.utils.string import match_endofsentence from pipecat.utils.string import match_endofsentence
from pipecat.utils.text.base_text_filter import BaseTextFilter from pipecat.utils.text.base_text_filter import BaseTextFilter
@@ -434,6 +436,12 @@ class TTSService(AIService):
class WordTTSService(TTSService): class WordTTSService(TTSService):
"""This is a base class for TTS services that support word timestamps. Word
timestamps are useful to synchronize audio with text of the spoken
words. This way only the spoken words are added to the conversation context.
"""
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._initial_word_timestamp = -1 self._initial_word_timestamp = -1
@@ -503,11 +511,93 @@ class WordTTSService(TTSService):
self._words_queue.task_done() self._words_queue.task_done()
class AudioContextWordTTSService(WordTTSService): class WebsocketTTSService(TTSService, WebsocketService):
"""This services allow us to send multiple TTS request to the services. Each """This is a base class for websocket-based TTS services."""
request could be multiple sentences long which are grouped by context. For
this to work, the TTS service needs to support handling multiple requests at def __init__(self, **kwargs):
once (i.e. multiple simultaneous contexts). TTSService.__init__(self, **kwargs)
WebsocketService.__init__(self)
class InterruptibleTTSService(WebsocketTTSService):
"""This is a base class for websocket-based TTS services that don't support
word timestamps and that don't offer a way to correlate the generated audio
to the requested text.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Indicates if the bot is speaking. If the bot is not speaking we don't
# need to reconnect when the user speaks. If the bot is speaking and the
# user interrupts we need to reconnect.
self._bot_speaking = False
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction)
if self._bot_speaking:
await self._disconnect()
await self._connect()
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BotStartedSpeakingFrame):
self._bot_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_speaking = False
class WebsocketWordTTSService(WordTTSService, WebsocketService):
"""This is a base class for websocket-based TTS services that support word
timestamps.
"""
def __init__(self, **kwargs):
WordTTSService.__init__(self, **kwargs)
WebsocketService.__init__(self)
class InterruptibleWordTTSService(WebsocketWordTTSService):
"""This is a base class for websocket-based TTS services that support word
timestamps but don't offer a way to correlate the generated audio to the
requested text.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Indicates if the bot is speaking. If the bot is not speaking we don't
# need to reconnect when the user speaks. If the bot is speaking and the
# user interrupts we need to reconnect.
self._bot_speaking = False
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction)
if self._bot_speaking:
await self._disconnect()
await self._connect()
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, BotStartedSpeakingFrame):
self._bot_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_speaking = False
class AudioContextWordTTSService(WebsocketWordTTSService):
"""This is a base class for websocket-based TTS services that support word
timestamps and also allow correlating the generated audio with the requested
text.
Each request could be multiple sentences long which are grouped by
context. For this to work, the TTS service needs to support handling
multiple requests at once (i.e. multiple simultaneous contexts).
The audio received from the TTS will be played in context order. That is, if The audio received from the TTS will be played in context order. That is, if
we requested audio for a context "A" and then audio for context "B", the we requested audio for a context "A" and then audio for context "B", the

View File

@@ -13,22 +13,18 @@ from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame, ErrorFrame,
Frame, Frame,
LLMFullResponseEndFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSAudioRawFrame, TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame, TTSStartedFrame,
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import AudioContextWordTTSService, TTSService from pipecat.services.ai_services import AudioContextWordTTSService, TTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
# See .env.example for Cartesia configuration needed # See .env.example for Cartesia configuration needed
@@ -75,7 +71,7 @@ def language_to_cartesia_language(language: Language) -> Optional[str]:
return result return result
class CartesiaTTSService(AudioContextWordTTSService, WebsocketService): class CartesiaTTSService(AudioContextWordTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
language: Optional[Language] = Language.EN language: Optional[Language] = Language.EN
speed: Optional[Union[str, float]] = "" speed: Optional[Union[str, float]] = ""
@@ -105,15 +101,13 @@ class CartesiaTTSService(AudioContextWordTTSService, WebsocketService):
# if we're interrupted. Cartesia gives us word-by-word timestamps. We # if we're interrupted. Cartesia gives us word-by-word timestamps. We
# 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!
AudioContextWordTTSService.__init__( super().__init__(
self,
aggregate_sentences=True, aggregate_sentences=True,
push_text_frames=False, push_text_frames=False,
pause_frame_processing=True, pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self)
self._api_key = api_key self._api_key = api_key
self._cartesia_version = cartesia_version self._cartesia_version = cartesia_version

View File

@@ -14,22 +14,18 @@ from loguru import logger
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame, ErrorFrame,
Frame, Frame,
LLMFullResponseEndFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSAudioRawFrame, TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame, TTSStartedFrame,
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import TTSService, WordTTSService from pipecat.services.ai_services import InterruptibleWordTTSService, TTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
# See .env.example for ElevenLabs configuration needed # See .env.example for ElevenLabs configuration needed
@@ -141,7 +137,7 @@ def calculate_word_times(
return word_times return word_times
class ElevenLabsTTSService(WordTTSService, WebsocketService): class ElevenLabsTTSService(InterruptibleWordTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
language: Optional[Language] = None language: Optional[Language] = None
optimize_streaming_latency: Optional[str] = None optimize_streaming_latency: Optional[str] = None
@@ -186,8 +182,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
# Finally, ElevenLabs doesn't provide information on when the bot stops # Finally, ElevenLabs doesn't provide information on when the bot stops
# 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.
WordTTSService.__init__( super().__init__(
self,
aggregate_sentences=True, aggregate_sentences=True,
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
@@ -195,7 +190,6 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self)
self._api_key = api_key self._api_key = api_key
self._url = url self._url = url

View File

@@ -22,8 +22,7 @@ from pipecat.frames.frames import (
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import InterruptibleTTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
try: try:
@@ -40,7 +39,7 @@ except ModuleNotFoundError as e:
FishAudioOutputFormat = Literal["opus", "mp3", "pcm", "wav"] FishAudioOutputFormat = Literal["opus", "mp3", "pcm", "wav"]
class FishAudioTTSService(TTSService, WebsocketService): class FishAudioTTSService(InterruptibleTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
language: Optional[Language] = Language.EN language: Optional[Language] = Language.EN
latency: Optional[str] = "normal" # "normal" or "balanced" latency: Optional[str] = "normal" # "normal" or "balanced"
@@ -110,11 +109,12 @@ class FishAudioTTSService(TTSService, WebsocketService):
self._receive_task = self.create_task(self._receive_task_handler(self.push_error)) self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self): async def _disconnect(self):
await self._disconnect_websocket()
if self._receive_task: if self._receive_task:
await self.cancel_task(self._receive_task) await self.cancel_task(self._receive_task)
self._receive_task = None self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self): async def _connect_websocket(self):
try: try:
logger.debug("Connecting to Fish Audio") logger.debug("Connecting to Fish Audio")
@@ -149,6 +149,11 @@ class FishAudioTTSService(TTSService, WebsocketService):
return self._websocket return self._websocket
raise Exception("Websocket not connected") raise Exception("Websocket not connected")
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction)
await self.stop_all_metrics()
self._request_id = None
async def _receive_messages(self): async def _receive_messages(self):
async for message in self._get_websocket(): async for message in self._get_websocket():
try: try:
@@ -168,11 +173,6 @@ class FishAudioTTSService(TTSService, WebsocketService):
except Exception as e: except Exception as e:
logger.error(f"Error processing message: {e}") logger.error(f"Error processing message: {e}")
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction)
await self.stop_all_metrics()
self._request_id = None
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
logger.debug(f"Generating Fish TTS: [{text}]") logger.debug(f"Generating Fish TTS: [{text}]")
try: try:

View File

@@ -21,8 +21,7 @@ from pipecat.frames.frames import (
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import InterruptibleTTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
# See .env.example for LMNT configuration needed # See .env.example for LMNT configuration needed
@@ -60,7 +59,7 @@ def language_to_lmnt_language(language: Language) -> Optional[str]:
return result return result
class LmntTTSService(TTSService, WebsocketService): class LmntTTSService(InterruptibleTTSService):
def __init__( def __init__(
self, self,
*, *,
@@ -70,14 +69,12 @@ class LmntTTSService(TTSService, WebsocketService):
language: Language = Language.EN, language: Language = Language.EN,
**kwargs, **kwargs,
): ):
TTSService.__init__( super().__init__(
self,
push_stop_frames=True, push_stop_frames=True,
pause_frame_processing=True, pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self)
self._api_key = api_key self._api_key = api_key
self._voice_id = voice_id self._voice_id = voice_id
@@ -116,12 +113,12 @@ class LmntTTSService(TTSService, WebsocketService):
self._receive_task = self.create_task(self._receive_task_handler(self.push_error)) self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self): async def _disconnect(self):
await self._disconnect_websocket()
if self._receive_task: if self._receive_task:
await self.cancel_task(self._receive_task) await self.cancel_task(self._receive_task)
self._receive_task = None self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self): async def _connect_websocket(self):
"""Connect to LMNT websocket.""" """Connect to LMNT websocket."""
try: try:
@@ -153,8 +150,9 @@ class LmntTTSService(TTSService, WebsocketService):
if self._websocket: if self._websocket:
logger.debug("Disconnecting from LMNT") logger.debug("Disconnecting from LMNT")
# Send EOF message before closing # NOTE(aleix): sending EOF message before closing is causing
await self._websocket.send(json.dumps({"eof": True})) # errors on the websocket, so we just skip it for now.
# await self._websocket.send(json.dumps({"eof": True}))
await self._websocket.close() await self._websocket.close()
self._websocket = None self._websocket = None

View File

@@ -16,22 +16,18 @@ from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame, ErrorFrame,
Frame, Frame,
LLMFullResponseEndFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSAudioRawFrame, TTSAudioRawFrame,
TTSSpeakFrame,
TTSStartedFrame, TTSStartedFrame,
TTSStoppedFrame, TTSStoppedFrame,
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import InterruptibleTTSService, TTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
try: try:
@@ -100,7 +96,7 @@ def language_to_playht_language(language: Language) -> Optional[str]:
return result return result
class PlayHTTTSService(TTSService, WebsocketService): class PlayHTTTSService(InterruptibleTTSService):
class InputParams(BaseModel): class InputParams(BaseModel):
language: Optional[Language] = Language.EN language: Optional[Language] = Language.EN
speed: Optional[float] = 1.0 speed: Optional[float] = 1.0
@@ -118,13 +114,11 @@ class PlayHTTTSService(TTSService, WebsocketService):
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
TTSService.__init__( super().__init__(
self,
pause_frame_processing=True, pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self)
self._api_key = api_key self._api_key = api_key
self._user_id = user_id self._user_id = user_id
@@ -168,12 +162,12 @@ class PlayHTTTSService(TTSService, WebsocketService):
self._receive_task = self.create_task(self._receive_task_handler(self.push_error)) self._receive_task = self.create_task(self._receive_task_handler(self.push_error))
async def _disconnect(self): async def _disconnect(self):
await self._disconnect_websocket()
if self._receive_task: if self._receive_task:
await self.cancel_task(self._receive_task) await self.cancel_task(self._receive_task)
self._receive_task = None self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self): async def _connect_websocket(self):
try: try:
logger.debug("Connecting to PlayHT") logger.debug("Connecting to PlayHT")

View File

@@ -26,7 +26,6 @@ from pipecat.frames.frames import (
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import AudioContextWordTTSService, TTSService from pipecat.services.ai_services import AudioContextWordTTSService, TTSService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
try: try:
@@ -55,7 +54,7 @@ def language_to_rime_language(language: Language) -> str:
return LANGUAGE_MAP.get(language, "eng") return LANGUAGE_MAP.get(language, "eng")
class RimeTTSService(AudioContextWordTTSService, WebsocketService): class RimeTTSService(AudioContextWordTTSService):
"""Text-to-Speech service using Rime's websocket API. """Text-to-Speech service using Rime's websocket API.
Uses Rime's websocket JSON API to convert text to speech with word-level timing Uses Rime's websocket JSON API to convert text to speech with word-level timing
@@ -92,8 +91,7 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
params: Additional configuration parameters. params: Additional configuration parameters.
""" """
# Initialize with parent class settings for proper frame handling # Initialize with parent class settings for proper frame handling
AudioContextWordTTSService.__init__( super().__init__(
self,
aggregate_sentences=True, aggregate_sentences=True,
push_text_frames=False, push_text_frames=False,
push_stop_frames=True, push_stop_frames=True,
@@ -101,7 +99,6 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
WebsocketService.__init__(self)
# Store service configuration # Store service configuration
self._api_key = api_key self._api_key = api_key
@@ -172,11 +169,12 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
async def _disconnect(self): async def _disconnect(self):
"""Close websocket connection and clean up tasks.""" """Close websocket connection and clean up tasks."""
await self._disconnect_websocket()
if self._receive_task: if self._receive_task:
await self.cancel_task(self._receive_task) await self.cancel_task(self._receive_task)
self._receive_task = None self._receive_task = None
await self._disconnect_websocket()
async def _connect_websocket(self): async def _connect_websocket(self):
"""Connect to Rime websocket API with configured settings.""" """Connect to Rime websocket API with configured settings."""
try: try:

View File

@@ -90,14 +90,32 @@ class WebsocketService(ABC):
logger.error(f"{self} reconnection failed: {reconnect_error}") logger.error(f"{self} reconnection failed: {reconnect_error}")
continue continue
@abstractmethod
async def _connect(self):
"""Implement service-specific connection logic. This function will
connect to the websocket via _connect_websocket() among other connection
logic."""
pass
@abstractmethod
async def _disconnect(self):
"""Implement service-specific disconnection logic. This function will
disconnect to the websocket via _connect_websocket() among other
connection logic.
"""
pass
@abstractmethod @abstractmethod
async def _connect_websocket(self): async def _connect_websocket(self):
"""Implement service-specific websocket connection logic.""" """Implement service-specific websocket connection logic. This function
should only connect to the websocket."""
pass pass
@abstractmethod @abstractmethod
async def _disconnect_websocket(self): async def _disconnect_websocket(self):
"""Implement service-specific websocket disconnection logic.""" """Implement service-specific websocket disconnection logic. This
function should only disconnect from the websocket."""
pass pass
@abstractmethod @abstractmethod