introduce text aggregators
This commit is contained in:
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added new `BaseTextAggregator`. Text aggregators are used by the TTS service
|
||||||
|
to aggregate LLM tokens and decide when the aggregated text should be pushed
|
||||||
|
to the TTS service. It also allows for the text to be manipulated while it's
|
||||||
|
being aggregated.
|
||||||
|
|
||||||
- Added new `UltravoxSTTService`.
|
- Added new `UltravoxSTTService`.
|
||||||
(see https://github.com/fixie-ai/ultravox)
|
(see https://github.com/fixie-ai/ultravox)
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ 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.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.text.base_text_aggregator import BaseTextAggregator
|
||||||
from pipecat.utils.text.base_text_filter import BaseTextFilter
|
from pipecat.utils.text.base_text_filter import BaseTextFilter
|
||||||
|
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
|
||||||
from pipecat.utils.time import seconds_to_nanoseconds
|
from pipecat.utils.time import seconds_to_nanoseconds
|
||||||
|
|
||||||
|
|
||||||
@@ -237,6 +238,9 @@ class TTSService(AIService):
|
|||||||
pause_frame_processing: bool = False,
|
pause_frame_processing: bool = False,
|
||||||
# TTS output sample rate
|
# TTS output sample rate
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
|
# Text aggregator to aggregate incoming tokens and decide when to push to the TTS.
|
||||||
|
text_aggregator: Optional[BaseTextAggregator] = None,
|
||||||
|
# Text filter executed after text has been aggregated.
|
||||||
text_filter: Optional[BaseTextFilter] = None,
|
text_filter: Optional[BaseTextFilter] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -252,12 +256,12 @@ class TTSService(AIService):
|
|||||||
self._sample_rate = 0
|
self._sample_rate = 0
|
||||||
self._voice_id: str = ""
|
self._voice_id: str = ""
|
||||||
self._settings: Dict[str, Any] = {}
|
self._settings: Dict[str, Any] = {}
|
||||||
|
self._text_aggregator: BaseTextAggregator = text_aggregator or SimpleTextAggregator()
|
||||||
self._text_filter: Optional[BaseTextFilter] = text_filter
|
self._text_filter: Optional[BaseTextFilter] = text_filter
|
||||||
|
|
||||||
self._stop_frame_task: Optional[asyncio.Task] = None
|
self._stop_frame_task: Optional[asyncio.Task] = None
|
||||||
self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
|
self._stop_frame_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|
||||||
self._current_sentence: str = ""
|
|
||||||
self._processing_text: bool = False
|
self._processing_text: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -336,8 +340,8 @@ class TTSService(AIService):
|
|||||||
# pause to avoid audio overlapping.
|
# pause to avoid audio overlapping.
|
||||||
await self._maybe_pause_frame_processing()
|
await self._maybe_pause_frame_processing()
|
||||||
|
|
||||||
sentence = self._current_sentence
|
sentence = self._text_aggregator.text
|
||||||
self._current_sentence = ""
|
self._text_aggregator.reset()
|
||||||
self._processing_text = False
|
self._processing_text = False
|
||||||
await self._push_tts_frames(sentence)
|
await self._push_tts_frames(sentence)
|
||||||
if isinstance(frame, LLMFullResponseEndFrame):
|
if isinstance(frame, LLMFullResponseEndFrame):
|
||||||
@@ -382,8 +386,8 @@ class TTSService(AIService):
|
|||||||
await self._stop_frame_queue.put(frame)
|
await self._stop_frame_queue.put(frame)
|
||||||
|
|
||||||
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
|
async def _handle_interruption(self, frame: StartInterruptionFrame, direction: FrameDirection):
|
||||||
self._current_sentence = ""
|
|
||||||
self._processing_text = False
|
self._processing_text = False
|
||||||
|
self._text_aggregator.handle_interruption()
|
||||||
if self._text_filter:
|
if self._text_filter:
|
||||||
self._text_filter.handle_interruption()
|
self._text_filter.handle_interruption()
|
||||||
|
|
||||||
@@ -400,11 +404,7 @@ class TTSService(AIService):
|
|||||||
if not self._aggregate_sentences:
|
if not self._aggregate_sentences:
|
||||||
text = frame.text
|
text = frame.text
|
||||||
else:
|
else:
|
||||||
self._current_sentence += frame.text
|
text = self._text_aggregator.aggregate(frame.text)
|
||||||
eos_end_marker = match_endofsentence(self._current_sentence)
|
|
||||||
if eos_end_marker:
|
|
||||||
text = self._current_sentence[:eos_end_marker]
|
|
||||||
self._current_sentence = self._current_sentence[eos_end_marker:]
|
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
await self._push_tts_frames(text)
|
await self._push_tts_frames(text)
|
||||||
|
|||||||
57
src/pipecat/utils/text/base_text_aggregator.py
Normal file
57
src/pipecat/utils/text/base_text_aggregator.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTextAggregator(ABC):
|
||||||
|
"""This is the base class for text aggregators. Text aggregators are usually
|
||||||
|
used by the TTS service to aggregate LLM tokens and decide when the
|
||||||
|
aggregated text should be pushed to the TTS service.
|
||||||
|
|
||||||
|
Text aggregators can also be used to manipulate text while it's being
|
||||||
|
aggregated (e.g. reasoning blocks can be removed).
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Returns the currently aggregated text."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def aggregate(self, text: str) -> Optional[str]:
|
||||||
|
"""Aggregates the specified text with the currently accumulated text.
|
||||||
|
|
||||||
|
This method should be implemented to define how the new text contributes
|
||||||
|
to the aggregation process. It returns the updated aggregated text if
|
||||||
|
it's ready to be processed, or None otherwise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The text to be aggregated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: The updated aggregated text or None if aggregated
|
||||||
|
text is not ready.
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_interruption(self):
|
||||||
|
"""Handles interruptions. When an interruption occurs it is possible
|
||||||
|
that we might want to discard the aggregated text or do some internal
|
||||||
|
modifications to the aggregated text.
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reset(self):
|
||||||
|
"""Clears the internally aggregated text."""
|
||||||
|
pass
|
||||||
42
src/pipecat/utils/text/simple_text_aggregator.py
Normal file
42
src/pipecat/utils/text/simple_text_aggregator.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pipecat.utils.string import match_endofsentence
|
||||||
|
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleTextAggregator(BaseTextAggregator):
|
||||||
|
"""This is a simple text aggregator. It aggregates text until an end of
|
||||||
|
sentence is found.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def aggregate(self, text: str) -> Optional[str]:
|
||||||
|
result: Optional[str] = None
|
||||||
|
|
||||||
|
self._text += text
|
||||||
|
|
||||||
|
eos_end_marker = match_endofsentence(self._text)
|
||||||
|
if eos_end_marker:
|
||||||
|
result = self._text[:eos_end_marker]
|
||||||
|
self._text = self._text[eos_end_marker:]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def handle_interruption(self):
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._text = ""
|
||||||
29
tests/test_simple_text_aggregator.py
Normal file
29
tests/test_simple_text_aggregator.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2025 Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.aggregator = SimpleTextAggregator()
|
||||||
|
|
||||||
|
async def test_reset_aggregations(self):
|
||||||
|
assert self.aggregator.aggregate("Hello ") == None
|
||||||
|
assert self.aggregator.text == "Hello "
|
||||||
|
self.aggregator.reset()
|
||||||
|
assert self.aggregator.text == ""
|
||||||
|
|
||||||
|
async def test_simple_sentence(self):
|
||||||
|
assert self.aggregator.aggregate("Hello ") == None
|
||||||
|
assert self.aggregator.aggregate("Pipecat!") == "Hello Pipecat!"
|
||||||
|
assert self.aggregator.text == ""
|
||||||
|
|
||||||
|
async def test_multiple_sentences(self):
|
||||||
|
assert self.aggregator.aggregate("Hello Pipecat! How are ") == "Hello Pipecat!"
|
||||||
|
assert self.aggregator.aggregate("you?") == " How are you?"
|
||||||
Reference in New Issue
Block a user