services: send TTSStartFrame/TTSStopFrame when really needed

This commit is contained in:
Aleix Conchillo Flaqué
2024-08-15 11:03:11 -07:00
parent b2a7ff6fd3
commit 67d565930e
9 changed files with 60 additions and 21 deletions

View File

@@ -20,12 +20,9 @@ from pipecat.frames.frames import (
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
TTSSpeakFrame, TTSSpeakFrame,
TTSStartedFrame,
TTSStoppedFrame,
TTSVoiceUpdateFrame, TTSVoiceUpdateFrame,
TextFrame, TextFrame,
VisionImageRawFrame, VisionImageRawFrame
FunctionCallResultFrame
) )
from pipecat.processors.async_frame_processor import AsyncFrameProcessor from pipecat.processors.async_frame_processor import AsyncFrameProcessor
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@@ -178,12 +175,12 @@ class TTSService(AIService):
self._push_text_frames: bool = push_text_frames self._push_text_frames: bool = push_text_frames
self._current_sentence: str = "" self._current_sentence: str = ""
@ abstractmethod @abstractmethod
async def set_voice(self, voice: str): async def set_voice(self, voice: str):
pass pass
# Converts the text to audio. # Converts the text to audio.
@ abstractmethod @abstractmethod
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
pass pass
@@ -212,11 +209,9 @@ class TTSService(AIService):
if not text: if not text:
return return
await self.push_frame(TTSStartedFrame())
await self.start_processing_metrics() await self.start_processing_metrics()
await self.process_generator(self.run_tts(text)) await self.process_generator(self.run_tts(text))
await self.stop_processing_metrics() await self.stop_processing_metrics()
await self.push_frame(TTSStoppedFrame())
if self._push_text_frames: if self._push_text_frames:
# We send the original text after the audio. This way, if we are # We send the original text after the audio. This way, if we are
# interrupted, the text is not added to the assistant context. # interrupted, the text is not added to the assistant context.
@@ -269,7 +264,7 @@ class STTService(AIService):
self._smoothing_factor = 0.2 self._smoothing_factor = 0.2
self._prev_volume = 0 self._prev_volume = 0
@ abstractmethod @abstractmethod
async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]: async def run_stt(self, audio: bytes) -> AsyncGenerator[Frame, None]:
"""Returns transcript as a string""" """Returns transcript as a string"""
pass pass
@@ -335,7 +330,7 @@ class ImageGenService(AIService):
super().__init__(**kwargs) super().__init__(**kwargs)
# Renders the image. Returns an Image object. # Renders the image. Returns an Image object.
@ abstractmethod @abstractmethod
async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]: async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]:
pass pass
@@ -358,7 +353,7 @@ class VisionService(AIService):
super().__init__(**kwargs) super().__init__(**kwargs)
self._describe_text = None self._describe_text = None
@ abstractmethod @abstractmethod
async def run_vision(self, frame: VisionImageRawFrame) -> AsyncGenerator[Frame, None]: async def run_vision(self, frame: VisionImageRawFrame) -> AsyncGenerator[Frame, None]:
pass pass

View File

@@ -18,9 +18,10 @@ from pipecat.frames.frames import (
EndFrame, EndFrame,
ErrorFrame, ErrorFrame,
Frame, Frame,
MetricsFrame,
StartFrame, StartFrame,
SystemFrame, SystemFrame,
TTSStartedFrame,
TTSStoppedFrame,
TranscriptionFrame, TranscriptionFrame,
URLImageRawFrame) URLImageRawFrame)
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
@@ -106,8 +107,10 @@ class AzureTTSService(TTSService):
if result.reason == ResultReason.SynthesizingAudioCompleted: if result.reason == ResultReason.SynthesizingAudioCompleted:
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
await self.push_frame(TTSStartedFrame())
# Azure always sends a 44-byte header. Strip it off. # Azure always sends a 44-byte header. Strip it off.
yield AudioRawFrame(audio=result.audio_data[44:], sample_rate=16000, num_channels=1) yield AudioRawFrame(audio=result.audio_data[44:], sample_rate=16000, num_channels=1)
await self.push_frame(TTSStoppedFrame())
elif result.reason == ResultReason.Canceled: elif result.reason == ResultReason.Canceled:
cancellation_details = result.cancellation_details cancellation_details = result.cancellation_details
logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}") logger.warning(f"Speech synthesis canceled: {cancellation_details.reason}")

View File

@@ -21,8 +21,9 @@ from pipecat.frames.frames import (
StartInterruptionFrame, StartInterruptionFrame,
StartFrame, StartFrame,
EndFrame, EndFrame,
TTSStartedFrame,
TTSStoppedFrame,
TextFrame, TextFrame,
MetricsFrame,
LLMFullResponseEndFrame LLMFullResponseEndFrame
) )
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import TTSService
@@ -154,6 +155,7 @@ class CartesiaTTSService(TTSService):
continue continue
if msg["type"] == "done": if msg["type"] == "done":
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
await self.push_frame(TTSStoppedFrame())
# Unset _context_id but not the _context_id_start_timestamp # Unset _context_id but not the _context_id_start_timestamp
# because we are likely still playing out audio and need the # because we are likely still playing out audio and need the
# timestamp to set send context frames. # timestamp to set send context frames.
@@ -176,6 +178,7 @@ class CartesiaTTSService(TTSService):
await self.push_frame(frame) await self.push_frame(frame)
elif msg["type"] == "error": elif msg["type"] == "error":
logger.error(f"{self} error: {msg}") logger.error(f"{self} error: {msg}")
await self.push_frame(TTSStoppedFrame())
await self.stop_all_metrics() await self.stop_all_metrics()
await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}')) await self.push_error(ErrorFrame(f'{self} error: {msg["error"]}'))
else: else:
@@ -214,6 +217,7 @@ class CartesiaTTSService(TTSService):
await self._connect() await self._connect()
if not self._context_id: if not self._context_id:
await self.push_frame(TTSStartedFrame())
await self.start_ttfb_metrics() await self.start_ttfb_metrics()
self._context_id = str(uuid.uuid4()) self._context_id = str(uuid.uuid4())
@@ -234,7 +238,8 @@ class CartesiaTTSService(TTSService):
await self._websocket.send(json.dumps(msg)) await self._websocket.send(json.dumps(msg))
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
except Exception as e: except Exception as e:
logger.exception(f"{self} error sending message: {e}") logger.error(f"{self} error sending message: {e}")
await self.push_frame(TTSStoppedFrame())
await self._disconnect() await self._disconnect()
await self._connect() await self._connect()
return return

View File

@@ -15,9 +15,10 @@ from pipecat.frames.frames import (
ErrorFrame, ErrorFrame,
Frame, Frame,
InterimTranscriptionFrame, InterimTranscriptionFrame,
MetricsFrame,
StartFrame, StartFrame,
SystemFrame, SystemFrame,
TTSStartedFrame,
TTSStoppedFrame,
TranscriptionFrame) TranscriptionFrame)
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import AsyncAIService, TTSService from pipecat.services.ai_services import AsyncAIService, TTSService
@@ -96,10 +97,12 @@ class DeepgramTTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
await self.push_frame(TTSStartedFrame())
async for data in r.content: async for data in r.content:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = AudioRawFrame(audio=data, sample_rate=self._sample_rate, num_channels=1) frame = AudioRawFrame(audio=data, sample_rate=self._sample_rate, num_channels=1)
yield frame yield frame
await self.push_frame(TTSStoppedFrame())
except Exception as e: except Exception as e:
logger.exception(f"{self} exception: {e}") logger.exception(f"{self} exception: {e}")

View File

@@ -9,7 +9,7 @@ import aiohttp
from typing import AsyncGenerator, Literal from typing import AsyncGenerator, Literal
from pydantic import BaseModel from pydantic import BaseModel
from pipecat.frames.frames import AudioRawFrame, ErrorFrame, Frame, MetricsFrame from pipecat.frames.frames import AudioRawFrame, ErrorFrame, Frame, TTSStartedFrame, TTSStoppedFrame
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import TTSService
from loguru import logger from loguru import logger
@@ -70,8 +70,10 @@ class ElevenLabsTTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
await self.push_frame(TTSStartedFrame())
async for chunk in r.content: async for chunk in r.content:
if len(chunk) > 0: if len(chunk) > 0:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = AudioRawFrame(chunk, 16000, 1) frame = AudioRawFrame(chunk, 16000, 1)
yield frame yield frame
await self.push_frame(TTSStoppedFrame())

View File

@@ -24,6 +24,8 @@ from pipecat.frames.frames import (
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
LLMMessagesFrame, LLMMessagesFrame,
LLMModelUpdateFrame, LLMModelUpdateFrame,
TTSStartedFrame,
TTSStoppedFrame,
TextFrame, TextFrame,
URLImageRawFrame, URLImageRawFrame,
VisionImageRawFrame, VisionImageRawFrame,
@@ -342,11 +344,13 @@ class OpenAITTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
await self.push_frame(TTSStartedFrame())
async for chunk in r.iter_bytes(8192): async for chunk in r.iter_bytes(8192):
if len(chunk) > 0: if len(chunk) > 0:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = AudioRawFrame(chunk, 24_000, 1) frame = AudioRawFrame(chunk, 24_000, 1)
yield frame yield frame
await self.push_frame(TTSStoppedFrame())
except BadRequestError as e: except BadRequestError as e:
logger.exception(f"{self} error generating TTS: {e}") logger.exception(f"{self} error generating TTS: {e}")

View File

@@ -9,7 +9,7 @@ import struct
from typing import AsyncGenerator from typing import AsyncGenerator
from pipecat.frames.frames import AudioRawFrame, Frame, MetricsFrame from pipecat.frames.frames import AudioRawFrame, Frame, TTSStartedFrame, TTSStoppedFrame
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import TTSService
from loguru import logger from loguru import logger
@@ -62,6 +62,7 @@ class PlayHTTTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
await self.push_frame(TTSStartedFrame())
async for chunk in playht_gen: async for chunk in playht_gen:
# skip the RIFF header. # skip the RIFF header.
if in_header: if in_header:
@@ -81,5 +82,6 @@ class PlayHTTTSService(TTSService):
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
frame = AudioRawFrame(chunk, 16000, 1) frame = AudioRawFrame(chunk, 16000, 1)
yield frame yield frame
await self.push_frame(TTSStoppedFrame())
except Exception as e: except Exception as e:
logger.exception(f"{self} error generating TTS: {e}") logger.exception(f"{self} error generating TTS: {e}")

View File

@@ -8,7 +8,13 @@ import aiohttp
from typing import Any, AsyncGenerator, Dict from typing import Any, AsyncGenerator, Dict
from pipecat.frames.frames import AudioRawFrame, ErrorFrame, Frame, MetricsFrame, StartFrame from pipecat.frames.frames import (
AudioRawFrame,
ErrorFrame,
Frame,
StartFrame,
TTSStartedFrame,
TTSStoppedFrame)
from pipecat.services.ai_services import TTSService from pipecat.services.ai_services import TTSService
from loguru import logger from loguru import logger
@@ -99,8 +105,9 @@ class XTTSService(TTSService):
await self.start_tts_usage_metrics(text) await self.start_tts_usage_metrics(text)
buffer = bytearray() await self.push_frame(TTSStartedFrame())
buffer = bytearray()
async for chunk in r.content.iter_chunked(1024): async for chunk in r.content.iter_chunked(1024):
if len(chunk) > 0: if len(chunk) > 0:
await self.stop_ttfb_metrics() await self.stop_ttfb_metrics()
@@ -131,3 +138,5 @@ class XTTSService(TTSService):
resampled_audio_bytes = resampled_audio.astype(np.int16).tobytes() resampled_audio_bytes = resampled_audio.astype(np.int16).tobytes()
frame = AudioRawFrame(resampled_audio_bytes, 16000, 1) frame = AudioRawFrame(resampled_audio_bytes, 16000, 1)
yield frame yield frame
await self.push_frame(TTSStoppedFrame())

View File

@@ -56,6 +56,11 @@ class BaseOutputTransport(FrameProcessor):
self._stopped_event = asyncio.Event() self._stopped_event = asyncio.Event()
# Indicates if the bot is currently speaking. This is useful when we
# have an interruption since all the queued messages will be thrown
# away and we would lose the TTSStoppedFrame.
self._bot_speaking = False
# Create sink frame task. This is the task that will actually write # Create sink frame task. This is the task that will actually write
# audio or video frames. We write audio/video in a task so we can keep # audio or video frames. We write audio/video in a task so we can keep
# generating frames upstream while, for example, the audio is playing. # generating frames upstream while, for example, the audio is playing.
@@ -169,6 +174,9 @@ class BaseOutputTransport(FrameProcessor):
self._push_frame_task.cancel() self._push_frame_task.cancel()
await self._push_frame_task await self._push_frame_task
self._create_push_task() self._create_push_task()
# Let's send a bot stopped speaking if we have to.
if self._bot_speaking:
await self._bot_stopped_speaking()
async def _handle_audio(self, frame: AudioRawFrame): async def _handle_audio(self, frame: AudioRawFrame):
if not self._params.audio_out_enabled: if not self._params.audio_out_enabled:
@@ -214,10 +222,10 @@ class BaseOutputTransport(FrameProcessor):
elif isinstance(frame, TransportMessageFrame): elif isinstance(frame, TransportMessageFrame):
await self.send_message(frame) await self.send_message(frame)
elif isinstance(frame, TTSStartedFrame): elif isinstance(frame, TTSStartedFrame):
await self._internal_push_frame(BotStartedSpeakingFrame(), FrameDirection.UPSTREAM) await self._bot_started_speaking()
await self._internal_push_frame(frame) await self._internal_push_frame(frame)
elif isinstance(frame, TTSStoppedFrame): elif isinstance(frame, TTSStoppedFrame):
await self._internal_push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM) await self._bot_stopped_speaking()
await self._internal_push_frame(frame) await self._internal_push_frame(frame)
else: else:
await self._internal_push_frame(frame) await self._internal_push_frame(frame)
@@ -230,6 +238,14 @@ class BaseOutputTransport(FrameProcessor):
except Exception as e: except Exception as e:
logger.exception(f"{self} error processing sink queue: {e}") logger.exception(f"{self} error processing sink queue: {e}")
async def _bot_started_speaking(self):
self._bot_speaking = True
await self._internal_push_frame(BotStartedSpeakingFrame(), FrameDirection.UPSTREAM)
async def _bot_stopped_speaking(self):
self._bot_speaking = False
await self._internal_push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
# #
# Push frames task # Push frames task
# #