Merge pull request #1225 from pipecat-ai/aleix/prepare-0.0.57

update CHANGELOG for 0.0.57
This commit is contained in:
Aleix Conchillo Flaqué
2025-02-14 18:50:08 -08:00
committed by GitHub
19 changed files with 110 additions and 106 deletions

View File

@@ -5,7 +5,7 @@ All notable changes to **Pipecat** will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [0.0.57] - 2025-02-14
### Added ### Added
@@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- We don't consider a colon `:` and end of sentence any more.
- Updated `DailyTransport` to respect the `audio_in_stream_on_start` field, - Updated `DailyTransport` to respect the `audio_in_stream_on_start` field,
ensuring it only starts receiving the audio input if it is enabled. ensuring it only starts receiving the audio input if it is enabled.
@@ -105,6 +107,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed a `FalImageGenService` issue that was causing the event loop to be
blocked while loading the downloadded image.
- Fixed a `CartesiaTTSService` service issue that would cause audio overlapping - Fixed a `CartesiaTTSService` service issue that would cause audio overlapping
in some cases. in some cases.
@@ -134,9 +139,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue[#1192] in 11labs where we are trying to reconnect/disconnect - Fixed an issue[#1192] in 11labs where we are trying to reconnect/disconnect
the websocket connection even when the connection is already closed. the websocket connection even when the connection is already closed.
- Fixed an issue where `has_regular_messages` condition was always been true in - Fixed an issue where `has_regular_messages` condition was always true in
`GoogleLLMContext` due to `Part` having `function_call` & `function_response` with `GoogleLLMContext` due to `Part` having `function_call` & `function_response`
`None` values. with `None` values.
### Other
- Added new `instant-voice` example. This example showcases how to enable
instant voice communication as soon as a user connects.
- Added new `local-input-select-stt` example. This examples allows you to play
with local audio inputs by slecting them through a nice text interface.
## [0.0.56] - 2025-02-06 ## [0.0.56] - 2025-02-06

View File

@@ -16,7 +16,7 @@ from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.services.cartesia import CartesiaTTSService from pipecat.services.cartesia import CartesiaTTSService
from pipecat.transports.local.audio import LocalAudioTransport, LocalTransportParams from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams
load_dotenv(override=True) load_dotenv(override=True)
@@ -25,7 +25,7 @@ logger.add(sys.stderr, level="DEBUG")
async def main(): async def main():
transport = LocalAudioTransport(LocalTransportParams(audio_out_enabled=True)) transport = LocalAudioTransport(LocalAudioTransportParams(audio_out_enabled=True))
tts = CartesiaTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),

View File

@@ -27,7 +27,7 @@ from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
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.cartesia import CartesiaHttpTTSService from pipecat.services.cartesia import CartesiaTTSService
from pipecat.services.openai import OpenAILLMService from pipecat.services.openai import OpenAILLMService
from pipecat.transports.services.daily import DailyParams, DailyTransport from pipecat.transports.services.daily import DailyParams, DailyTransport
@@ -91,7 +91,7 @@ async def main():
), ),
) )
tts = CartesiaHttpTTSService( tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady voice_id="79a125e8-cd45-4c13-8a67-188112f4dd22", # British Lady
) )

View File

@@ -16,7 +16,7 @@ from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.whisper import WhisperSTTService from pipecat.services.whisper import WhisperSTTService
from pipecat.transports.local.audio import LocalAudioTransport, LocalTransportParams from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams
load_dotenv(override=True) load_dotenv(override=True)
@@ -33,7 +33,7 @@ class TranscriptionLogger(FrameProcessor):
async def main(): async def main():
transport = LocalAudioTransport(LocalTransportParams(audio_in_enabled=True)) transport = LocalAudioTransport(LocalAudioTransportParams(audio_in_enabled=True))
stt = WhisperSTTService() stt = WhisperSTTService()

View File

@@ -18,7 +18,7 @@ from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.whisper import Model, WhisperSTTService from pipecat.services.whisper import Model, WhisperSTTService
from pipecat.transports.local.audio import LocalAudioTransport, LocalTransportParams from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams
load_dotenv(override=True) load_dotenv(override=True)
@@ -36,7 +36,7 @@ class TranscriptionLogger(FrameProcessor):
async def main(input_device: int, output_device: int): async def main(input_device: int, output_device: int):
transport = LocalAudioTransport( transport = LocalAudioTransport(
LocalTransportParams( LocalAudioTransportParams(
audio_in_enabled=True, audio_in_enabled=True,
audio_out_enabled=False, audio_out_enabled=False,
input_device_index=input_device, input_device_index=input_device,

View File

@@ -10,7 +10,6 @@ from abc import abstractmethod
from typing import List from typing import List
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotInterruptionFrame,
CancelFrame, CancelFrame,
EmulateUserStartedSpeakingFrame, EmulateUserStartedSpeakingFrame,
EmulateUserStoppedSpeakingFrame, EmulateUserStoppedSpeakingFrame,
@@ -281,6 +280,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
await self._cancel_aggregation_task() await self._cancel_aggregation_task()
async def _handle_user_started_speaking(self, _: UserStartedSpeakingFrame): async def _handle_user_started_speaking(self, _: UserStartedSpeakingFrame):
self._last_user_speaking_time = time.time()
self._user_speaking = True self._user_speaking = True
async def _handle_user_stopped_speaking(self, _: UserStoppedSpeakingFrame): async def _handle_user_stopped_speaking(self, _: UserStoppedSpeakingFrame):
@@ -358,6 +358,8 @@ class LLMAssistantContextAggregator(LLMContextResponseAggregator):
super().__init__(context=context, role="assistant", **kwargs) super().__init__(context=context, role="assistant", **kwargs)
self._expect_stripped_words = expect_stripped_words self._expect_stripped_words = expect_stripped_words
self._started = False
self.reset() self.reset()
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
@@ -420,7 +422,7 @@ class LLMUserResponseAggregator(LLMUserContextAggregator):
class LLMAssistantResponseAggregator(LLMAssistantContextAggregator): class LLMAssistantResponseAggregator(LLMAssistantContextAggregator):
def __init__(self, messages: List[dict], **kwargs): def __init__(self, messages: List[dict] = [], **kwargs):
super().__init__(context=OpenAILLMContext(messages), **kwargs) super().__init__(context=OpenAILLMContext(messages), **kwargs)
async def push_aggregation(self): async def push_aggregation(self):

View File

@@ -73,10 +73,11 @@ class FrameProcessor:
self._metrics.set_processor_name(self.name) self._metrics.set_processor_name(self.name)
# Processors have an input queue. The input queue will be processed # Processors have an input queue. The input queue will be processed
# immediately (default) or it will block if `pause_processing_frames()` is # immediately (default) or it will block if `pause_processing_frames()`
# called. To resume processing frames we need to call # is called. To resume processing frames we need to call
# `resume_processing_frames()`. # `resume_processing_frames()` which will wake up the event.
self.__should_block_frames = False self.__should_block_frames = False
self.__input_event = asyncio.Event()
self.__input_frame_task: Optional[asyncio.Task] = None self.__input_frame_task: Optional[asyncio.Task] = None
# Every processor in Pipecat should only output frames from a single # Every processor in Pipecat should only output frames from a single
@@ -335,8 +336,8 @@ class FrameProcessor:
def __create_input_task(self): def __create_input_task(self):
if not self.__input_frame_task: if not self.__input_frame_task:
self.__should_block_frames = False self.__should_block_frames = False
self.__input_event.clear()
self.__input_queue = asyncio.Queue() self.__input_queue = asyncio.Queue()
self.__input_event = asyncio.Event()
self.__input_frame_task = self.create_task(self.__input_frame_task_handler()) self.__input_frame_task = self.create_task(self.__input_frame_task_handler())
async def __cancel_input_task(self): async def __cancel_input_task(self):

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,
BotStoppedSpeakingFrame,
CancelFrame, CancelFrame,
EndFrame, EndFrame,
ErrorFrame, ErrorFrame,
@@ -75,13 +76,13 @@ class AIService(FrameProcessor):
) )
for key, value in settings.items(): for key, value in settings.items():
print("Update request for:", key, value) logger.debug("Update request for:", key, value)
if key in self._settings: if key in self._settings:
logger.info(f"Updating LLM setting {key} to: [{value}]") logger.info(f"Updating LLM setting {key} to: [{value}]")
self._settings[key] = value self._settings[key] = value
elif key in SessionProperties.model_fields: elif key in SessionProperties.model_fields:
print("Attempting to update", key, value) logger.debug("Attempting to update", key, value)
try: try:
from pipecat.services.openai_realtime_beta.events import ( from pipecat.services.openai_realtime_beta.events import (
@@ -212,6 +213,8 @@ class TTSService(AIService):
push_silence_after_stop: bool = False, push_silence_after_stop: bool = False,
# if push_silence_after_stop is True, send this amount of audio silence # if push_silence_after_stop is True, send this amount of audio silence
silence_time_s: float = 2.0, silence_time_s: float = 2.0,
# if True, we will pause processing frames while we are receiving audio
pause_frame_processing: bool = False,
# TTS output sample rate # TTS output sample rate
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
text_filter: Optional[BaseTextFilter] = None, text_filter: Optional[BaseTextFilter] = None,
@@ -224,6 +227,7 @@ class TTSService(AIService):
self._stop_frame_timeout_s: float = stop_frame_timeout_s self._stop_frame_timeout_s: float = stop_frame_timeout_s
self._push_silence_after_stop: bool = push_silence_after_stop self._push_silence_after_stop: bool = push_silence_after_stop
self._silence_time_s: float = silence_time_s self._silence_time_s: float = silence_time_s
self._pause_frame_processing: bool = pause_frame_processing
self._init_sample_rate = sample_rate self._init_sample_rate = sample_rate
self._sample_rate = 0 self._sample_rate = 0
self._voice_id: str = "" self._voice_id: str = ""
@@ -234,6 +238,7 @@ class TTSService(AIService):
self._stop_frame_queue: asyncio.Queue = asyncio.Queue() self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
self._current_sentence: str = "" self._current_sentence: str = ""
self._processing_text: bool = False
@property @property
def sample_rate(self) -> int: def sample_rate(self) -> int:
@@ -299,6 +304,7 @@ class TTSService(AIService):
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)
if ( if (
isinstance(frame, TextFrame) isinstance(frame, TextFrame)
and not isinstance(frame, InterimTranscriptionFrame) and not isinstance(frame, InterimTranscriptionFrame)
@@ -307,9 +313,16 @@ class TTSService(AIService):
await self._process_text_frame(frame) await self._process_text_frame(frame)
elif isinstance(frame, StartInterruptionFrame): elif isinstance(frame, StartInterruptionFrame):
await self._handle_interruption(frame, direction) await self._handle_interruption(frame, direction)
await self.push_frame(frame, direction)
elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)): elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
# We pause processing incoming frames if the LLM response included
# text (it might be that it's only a function calling response). We
# pause to avoid audio overlapping.
await self._maybe_pause_frame_processing()
sentence = self._current_sentence sentence = self._current_sentence
self._current_sentence = "" self._current_sentence = ""
self._processing_text = False
await self._push_tts_frames(sentence) await self._push_tts_frames(sentence)
if isinstance(frame, LLMFullResponseEndFrame): if isinstance(frame, LLMFullResponseEndFrame):
if self._push_text_frames: if self._push_text_frames:
@@ -318,9 +331,16 @@ class TTSService(AIService):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, TTSSpeakFrame): elif isinstance(frame, TTSSpeakFrame):
await self._push_tts_frames(frame.text) await self._push_tts_frames(frame.text)
# We pause processing incoming frames because we are sending data to
# the TTS. We pause to avoid audio overlapping.
await self._maybe_pause_frame_processing()
await self.flush_audio() await self.flush_audio()
self._processing_text = False
elif isinstance(frame, TTSUpdateSettingsFrame): elif isinstance(frame, TTSUpdateSettingsFrame):
await self._update_settings(frame.settings) await self._update_settings(frame.settings)
elif isinstance(frame, BotStoppedSpeakingFrame):
await self._maybe_resume_frame_processing()
await self.push_frame(frame, direction)
else: else:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -347,9 +367,17 @@ class TTSService(AIService):
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection): async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
self._current_sentence = "" self._current_sentence = ""
self._processing_text = False
if self._text_filter: if self._text_filter:
self._text_filter.handle_interruption() self._text_filter.handle_interruption()
await self.push_frame(frame, direction)
async def _maybe_pause_frame_processing(self):
if self._processing_text and self._pause_frame_processing:
await self.pause_processing_frames()
async def _maybe_resume_frame_processing(self):
if self._pause_frame_processing:
await self.resume_processing_frames()
async def _process_text_frame(self, frame: TextFrame): async def _process_text_frame(self, frame: TextFrame):
text: Optional[str] = None text: Optional[str] = None
@@ -371,6 +399,11 @@ class TTSService(AIService):
if not text.strip(): if not text.strip():
return return
# This is just a flag that indicates if we sent something to the TTS
# service. It will be cleared if we sent text because of a TTSSpeakFrame
# or when we received an LLMFullResponseEndFrame
self._processing_text = True
await self.start_processing_metrics() await self.start_processing_metrics()
if self._text_filter: if self._text_filter:
self._text_filter.reset_interruption() self._text_filter.reset_interruption()

View File

@@ -109,6 +109,7 @@ class CartesiaTTSService(AudioContextWordTTSService, WebsocketService):
self, self,
aggregate_sentences=True, aggregate_sentences=True,
push_text_frames=False, push_text_frames=False,
pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
@@ -274,19 +275,6 @@ class CartesiaTTSService(AudioContextWordTTSService, WebsocketService):
else: else:
logger.error(f"{self} error, unknown message type: {msg}") logger.error(f"{self} error, unknown message type: {msg}")
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
# If we received a TTSSpeakFrame and the LLM response included text (it
# might be that it's only a function calling response) we pause
# processing more frames until we receive a BotStoppedSpeakingFrame.
if isinstance(frame, TTSSpeakFrame):
await self.pause_processing_frames()
elif isinstance(frame, LLMFullResponseEndFrame) and self._context_id:
await self.pause_processing_frames()
elif isinstance(frame, BotStoppedSpeakingFrame):
await self.resume_processing_frames()
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

@@ -192,6 +192,7 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
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,
pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
@@ -289,19 +290,6 @@ class ElevenLabsTTSService(WordTTSService, WebsocketService):
if isinstance(frame, TTSStoppedFrame): if isinstance(frame, TTSStoppedFrame):
await self.add_word_timestamps([("LLMFullResponseEndFrame", 0), ("Reset", 0)]) await self.add_word_timestamps([("LLMFullResponseEndFrame", 0), ("Reset", 0)])
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
# If we received a TTSSpeakFrame and the LLM response included text (it
# might be that it's only a function calling response) we pause
# processing more frames until we receive a BotStoppedSpeakingFrame.
if isinstance(frame, TTSSpeakFrame):
await self.pause_processing_frames()
elif isinstance(frame, LLMFullResponseEndFrame) and self._started:
await self.pause_processing_frames()
elif isinstance(frame, BotStoppedSpeakingFrame):
await self.resume_processing_frames()
async def _connect(self): async def _connect(self):
await self._connect_websocket() await self._connect_websocket()

View File

@@ -4,6 +4,7 @@
# SPDX-License-Identifier: BSD 2-Clause License # SPDX-License-Identifier: BSD 2-Clause License
# #
import asyncio
import io import io
import os import os
from typing import AsyncGenerator, Dict, Optional, Union from typing import AsyncGenerator, Dict, Optional, Union
@@ -53,6 +54,11 @@ class FalImageGenService(ImageGenService):
os.environ["FAL_KEY"] = key os.environ["FAL_KEY"] = key
async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]: async def run_image_gen(self, prompt: str) -> AsyncGenerator[Frame, None]:
def load_image_bytes(encoded_image: bytes):
buffer = io.BytesIO(encoded_image)
image = Image.open(buffer)
return (image.tobytes(), image.size, image.format)
logger.debug(f"Generating image from prompt: {prompt}") logger.debug(f"Generating image from prompt: {prompt}")
response = await fal_client.run_async( response = await fal_client.run_async(
@@ -73,10 +79,8 @@ class FalImageGenService(ImageGenService):
logger.debug(f"Downloading image {image_url} ...") logger.debug(f"Downloading image {image_url} ...")
async with self._aiohttp_session.get(image_url) as response: async with self._aiohttp_session.get(image_url) as response:
logger.debug(f"Downloaded image {image_url}") logger.debug(f"Downloaded image {image_url}")
image_stream = io.BytesIO(await response.content.read()) encoded_image = await response.content.read()
image = Image.open(image_stream) (image_bytes, size, format) = await asyncio.to_thread(load_image_bytes, encoded_image)
frame = URLImageRawFrame( frame = URLImageRawFrame(url=image_url, image=image_bytes, size=size, format=format)
url=image_url, image=image.tobytes(), size=image.size, format=image.format
)
yield frame yield frame

View File

@@ -60,7 +60,7 @@ class FishAudioTTSService(TTSService, WebsocketService):
params: InputParams = InputParams(), params: InputParams = InputParams(),
**kwargs, **kwargs,
): ):
super().__init__(sample_rate=sample_rate, **kwargs) super().__init__(pause_frame_processing=True, sample_rate=sample_rate, **kwargs)
self._api_key = api_key self._api_key = api_key
self._base_url = "wss://api.fish.audio/v1/tts/live" self._base_url = "wss://api.fish.audio/v1/tts/live"
@@ -166,16 +166,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 process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
await self.pause_processing_frames()
elif isinstance(frame, LLMFullResponseEndFrame) and self._request_id:
await self.pause_processing_frames()
elif isinstance(frame, BotStoppedSpeakingFrame):
await self.resume_processing_frames()
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection): async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
await super()._handle_interruption(frame, direction) await super()._handle_interruption(frame, direction)
await self.stop_all_metrics() await self.stop_all_metrics()

View File

@@ -73,6 +73,7 @@ class LmntTTSService(TTSService, WebsocketService):
TTSService.__init__( TTSService.__init__(
self, self,
push_stop_frames=True, push_stop_frames=True,
pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )

View File

@@ -120,6 +120,7 @@ class PlayHTTTSService(TTSService, WebsocketService):
): ):
TTSService.__init__( TTSService.__init__(
self, self,
pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
@@ -269,19 +270,6 @@ class PlayHTTTSService(TTSService, WebsocketService):
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error(f"Invalid JSON message: {message}") logger.error(f"Invalid JSON message: {message}")
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
# If we received a TTSSpeakFrame and the LLM response included text (it
# might be that it's only a function calling response) we pause
# processing more frames until we receive a BotStoppedSpeakingFrame.
if isinstance(frame, TTSSpeakFrame):
await self.pause_processing_frames()
elif isinstance(frame, LLMFullResponseEndFrame) and self._request_id:
await self.pause_processing_frames()
elif isinstance(frame, BotStoppedSpeakingFrame):
await self.resume_processing_frames()
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

@@ -101,6 +101,7 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
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,
pause_frame_processing=True,
sample_rate=sample_rate, sample_rate=sample_rate,
**kwargs, **kwargs,
) )
@@ -126,7 +127,6 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
# State tracking # State tracking
self._context_id = None # Tracks current turn self._context_id = None # Tracks current turn
self._receive_task = None self._receive_task = None
self._started = False
self._cumulative_time = 0 # Accumulates time across messages self._cumulative_time = 0 # Accumulates time across messages
def can_generate_metrics(self) -> bool: def can_generate_metrics(self) -> bool:
@@ -200,7 +200,6 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
await self._websocket.send(json.dumps(self._build_eos_msg())) await self._websocket.send(json.dumps(self._build_eos_msg()))
await self._websocket.close() await self._websocket.close()
self._websocket = None self._websocket = None
self._started = False
self._context_id = None self._context_id = None
except Exception as e: except Exception as e:
logger.error(f"{self} error closing websocket: {e}") logger.error(f"{self} error closing websocket: {e}")
@@ -217,7 +216,6 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
await self.stop_all_metrics() await self.stop_all_metrics()
if self._context_id: if self._context_id:
await self._get_websocket().send(json.dumps(self._build_clear_msg())) await self._get_websocket().send(json.dumps(self._build_clear_msg()))
self._started = False
self._context_id = None self._context_id = None
def _calculate_word_times(self, words: list, starts: list, ends: list) -> list: def _calculate_word_times(self, words: list, starts: list, ends: list) -> list:
@@ -300,21 +298,9 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
"""Push frame and handle end-of-turn conditions.""" """Push frame and handle end-of-turn conditions."""
await super().push_frame(frame, direction) await super().push_frame(frame, direction)
if isinstance(frame, (TTSStoppedFrame, StartInterruptionFrame)): if isinstance(frame, (TTSStoppedFrame, StartInterruptionFrame)):
self._started = False
if isinstance(frame, TTSStoppedFrame): if isinstance(frame, TTSStoppedFrame):
await self.add_word_timestamps([("LLMFullResponseEndFrame", 0), ("Reset", 0)]) await self.add_word_timestamps([("LLMFullResponseEndFrame", 0), ("Reset", 0)])
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and manage turn state."""
await super().process_frame(frame, direction)
if isinstance(frame, TTSSpeakFrame):
await self.pause_processing_frames()
elif isinstance(frame, LLMFullResponseEndFrame) and self._started:
await self.pause_processing_frames()
elif isinstance(frame, BotStoppedSpeakingFrame):
await self.resume_processing_frames()
async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]:
"""Generate speech from text. """Generate speech from text.
@@ -330,10 +316,9 @@ class RimeTTSService(AudioContextWordTTSService, WebsocketService):
await self._connect() await self._connect()
try: try:
if not self._started: if not self._context_id:
await self.start_ttfb_metrics() await self.start_ttfb_metrics()
yield TTSStartedFrame() yield TTSStartedFrame()
self._started = True
self._cumulative_time = 0 self._cumulative_time = 0
self._context_id = str(uuid.uuid4()) self._context_id = str(uuid.uuid4())
await self.create_audio_context(self._context_id) await self.create_audio_context(self._context_id)

View File

@@ -26,17 +26,18 @@ except ModuleNotFoundError as e:
raise Exception(f"Missing module: {e}") raise Exception(f"Missing module: {e}")
class LocalTransportParams(TransportParams): class LocalAudioTransportParams(TransportParams):
input_device_index: int = 0 input_device_index: Optional[int] = None
output_device_index: int = 0 output_device_index: Optional[int] = None
class LocalAudioInputTransport(BaseInputTransport): class LocalAudioInputTransport(BaseInputTransport):
_params: LocalTransportParams _params: LocalAudioTransportParams
def __init__(self, py_audio: pyaudio.PyAudio, params: LocalTransportParams): def __init__(self, py_audio: pyaudio.PyAudio, params: LocalAudioTransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio self._py_audio = py_audio
self._in_stream = None self._in_stream = None
self._sample_rate = 0 self._sample_rate = 0
@@ -77,11 +78,12 @@ class LocalAudioInputTransport(BaseInputTransport):
class LocalAudioOutputTransport(BaseOutputTransport): class LocalAudioOutputTransport(BaseOutputTransport):
_params: LocalTransportParams _params: LocalAudioTransportParams
def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, py_audio: pyaudio.PyAudio, params: LocalAudioTransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio self._py_audio = py_audio
self._out_stream = None self._out_stream = None
self._sample_rate = 0 self._sample_rate = 0
@@ -117,7 +119,7 @@ class LocalAudioOutputTransport(BaseOutputTransport):
class LocalAudioTransport(BaseTransport): class LocalAudioTransport(BaseTransport):
def __init__(self, params: LocalTransportParams): def __init__(self, params: LocalAudioTransportParams):
super().__init__() super().__init__()
self._params = params self._params = params
self._pyaudio = pyaudio.PyAudio() self._pyaudio = pyaudio.PyAudio()

View File

@@ -34,8 +34,15 @@ except ModuleNotFoundError as e:
raise Exception(f"Missing module: {e}") raise Exception(f"Missing module: {e}")
class TkTransportParams(TransportParams):
audio_input_device_index: Optional[int] = None
audio_output_device_index: Optional[int] = None
class TkInputTransport(BaseInputTransport): class TkInputTransport(BaseInputTransport):
def __init__(self, py_audio: pyaudio.PyAudio, params: TransportParams): _params: TkTransportParams
def __init__(self, py_audio: pyaudio.PyAudio, params: TkTransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio self._py_audio = py_audio
self._in_stream = None self._in_stream = None
@@ -54,6 +61,7 @@ class TkInputTransport(BaseInputTransport):
frames_per_buffer=num_frames, frames_per_buffer=num_frames,
stream_callback=self._audio_in_callback, stream_callback=self._audio_in_callback,
input=True, input=True,
input_device_index=self._params.audio_input_device_index,
) )
self._in_stream.start_stream() self._in_stream.start_stream()
@@ -76,6 +84,8 @@ class TkInputTransport(BaseInputTransport):
class TkOutputTransport(BaseOutputTransport): class TkOutputTransport(BaseOutputTransport):
_params: TkTransportParams
def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TransportParams): def __init__(self, tk_root: tk.Tk, py_audio: pyaudio.PyAudio, params: TransportParams):
super().__init__(params) super().__init__(params)
self._py_audio = py_audio self._py_audio = py_audio
@@ -103,6 +113,7 @@ class TkOutputTransport(BaseOutputTransport):
channels=self._params.audio_out_channels, channels=self._params.audio_out_channels,
rate=self._sample_rate, rate=self._sample_rate,
output=True, output=True,
output_device_index=self._params.audio_output_device_index,
) )
self._out_stream.start_stream() self._out_stream.start_stream()

View File

@@ -13,8 +13,8 @@ ENDOFSENTENCE_PATTERN_STR = r"""
(?<!Mr|Ms|Dr) # Negative lookbehind: not preceded by Mr, Ms, Dr (combined bc. length is the same) (?<!Mr|Ms|Dr) # Negative lookbehind: not preceded by Mr, Ms, Dr (combined bc. length is the same)
(?<!Mrs) # Negative lookbehind: not preceded by "Mrs" (?<!Mrs) # Negative lookbehind: not preceded by "Mrs"
(?<!Prof) # Negative lookbehind: not preceded by "Prof" (?<!Prof) # Negative lookbehind: not preceded by "Prof"
[\.\?\!:;]| # Match a period, question mark, exclamation point, colon, or semicolon [\.\?\!;]| # Match a period, question mark, exclamation point, or semicolon
[。?!;।] # the full-width version (mainly used in East Asian languages such as Chinese, Hindi) [。?!;।] # the full-width version (mainly used in East Asian languages such as Chinese, Hindi)
$ # End of string $ # End of string
""" """
ENDOFSENTENCE_PATTERN = re.compile(ENDOFSENTENCE_PATTERN_STR, re.VERBOSE) ENDOFSENTENCE_PATTERN = re.compile(ENDOFSENTENCE_PATTERN_STR, re.VERBOSE)

View File

@@ -14,7 +14,6 @@ class TestUtilsString(unittest.IsolatedAsyncioTestCase):
assert match_endofsentence("This is a sentence.") assert match_endofsentence("This is a sentence.")
assert match_endofsentence("This is a sentence! ") assert match_endofsentence("This is a sentence! ")
assert match_endofsentence("This is a sentence?") assert match_endofsentence("This is a sentence?")
assert match_endofsentence("This is a sentence:")
assert match_endofsentence("This is a sentence;") assert match_endofsentence("This is a sentence;")
assert not match_endofsentence("This is not a sentence") assert not match_endofsentence("This is not a sentence")
assert not match_endofsentence("This is not a sentence,") assert not match_endofsentence("This is not a sentence,")
@@ -33,7 +32,6 @@ class TestUtilsString(unittest.IsolatedAsyncioTestCase):
"你好!", "你好!",
"吃了吗?", "吃了吗?",
"安全第一;", "安全第一;",
"他说:",
] ]
for i in chinese_sentences: for i in chinese_sentences:
assert match_endofsentence(i) assert match_endofsentence(i)