Final PR Feedback changes

This commit is contained in:
mattie ruth backman
2025-11-14 13:51:31 -05:00
parent 71b87fd420
commit 713b488bb6
5 changed files with 56 additions and 43 deletions

View File

@@ -584,36 +584,38 @@ class TTSService(AIService):
await filter.reset_interruption() await filter.reset_interruption()
text = await filter.filter(text) text = await filter.filter(text)
if text: if not text.strip():
if not self._push_text_frames: await self.stop_processing_metrics()
# In a typical pipeline, there is an assistant context aggregator return
# that listens for TTSTextFrames to add spoken text to the context.
# If the TTS service supports word timestamps, then _push_text_frames # To support use cases that may want to know the text before it's spoken, we
# is set to False and these are sent word by word as part of the # push the AggregatedTextFrame version before transforming and sending to TTS.
# _words_task_handler in the WordTTSService subclass. However, to # However, we do not want to add this text to the assistant context until it
# support use cases where an observer may want the full text before # is spoken, so we set append_to_context to False.
# the audio is generated, we send along the AggregatedTextFrame here, src_frame.append_to_context = False
# but we set append_to_context to False so it does not cause duplication await self.push_frame(src_frame)
# in the context. This is primarily used by the RTVIObserver to
# generate a complete bot-output. # Note: Text transformations are meant to only affect the text sent to the TTS for
src_frame.append_to_context = False # TTS-specific purposes. This allows for explicit TTS modifications (e.g., inserting
await self.push_frame(src_frame) # TTS supported tags for spelling or emotion or replacing an @ with "at"). For TTS
# Note: Text transformations only affect the text sent to the TTS. This allows # services that support word-level timestamps, this CAN affect the resulting context
# for explicit TTS-specific modifications (e.g., inserting TTS supported tags # since the TTSTextFrames are generated from the TTS output stream
# for spelling or emotion or replacing an @ with "at"). For TTS services that transformed_text = text
# support word-level timestamps, this DOES affect the resulting context as the for aggregation_type, transform in self._text_transforms:
# the context is built from the TTSTextFrames generated during word timestamping. if aggregation_type == type or aggregation_type == "*":
for aggregation_type, transform in self._text_transforms: transformed_text = await transform(transformed_text, type)
if aggregation_type == type or aggregation_type == "*": await self.process_generator(self.run_tts(transformed_text))
text = await transform(text, type)
await self.process_generator(self.run_tts(text))
await self.stop_processing_metrics() await self.stop_processing_metrics()
if self._push_text_frames: if self._push_text_frames:
# In the case where the TTS service does not support word timestamps, # In TTS services that support word timestamps, the TTSTextFrames
# we send the full aggregated text after the audio. This way, if we are # are pushed as words are spoken. However, in the case where the TTS service
# interrupted, the text is not added to the assistant context. # does not support word timestamps (i.e. _push_text_frames is True), we send
# the original (non-transformed) text after the TTS generation has completed.
# This way, if we are interrupted, the text is not added to the assistant
# context and the context that IS added does not include TTS-specific tags
# or transformations.
frame = TTSTextFrame(text, aggregated_by=type) frame = TTSTextFrame(text, aggregated_by=type)
frame.includes_inter_frame_spaces = self.includes_inter_frame_spaces frame.includes_inter_frame_spaces = self.includes_inter_frame_spaces
await self.push_frame(frame) await self.push_frame(frame)

View File

@@ -13,9 +13,20 @@ aggregated text should be sent for speech synthesis.
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from typing import Optional from typing import Optional
class AggregationType(str, Enum):
"""Built-in aggregation strings."""
SENTENCE = "sentence"
WORD = "word"
def __str__(self):
return self.value
@dataclass @dataclass
class Aggregation: class Aggregation:
"""Data class representing aggregated text and its type. """Data class representing aggregated text and its type.

View File

@@ -18,7 +18,7 @@ from typing import Awaitable, Callable, List, Optional, Tuple
from loguru import logger from loguru import logger
from pipecat.utils.string import match_endofsentence from pipecat.utils.string import match_endofsentence
from pipecat.utils.text.base_text_aggregator import Aggregation, BaseTextAggregator from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator
class MatchAction(Enum): class MatchAction(Enum):
@@ -110,8 +110,8 @@ class PatternPairAggregator(BaseTextAggregator):
""" """
pattern_start = self._match_start_of_pattern(self._text) pattern_start = self._match_start_of_pattern(self._text)
if pattern_start: if pattern_start:
return Aggregation(self._text, pattern_start[1].get("type", "sentence")) return Aggregation(self._text, pattern_start[1].get("type", AggregationType.SENTENCE))
return Aggregation(self._text, "sentence") return Aggregation(self._text, AggregationType.SENTENCE)
def add_pattern( def add_pattern(
self, self,
@@ -128,8 +128,8 @@ class PatternPairAggregator(BaseTextAggregator):
Args: Args:
type: Identifier for this pattern pair. Should be unique and ideally descriptive. type: Identifier for this pattern pair. Should be unique and ideally descriptive.
(e.g., 'code', 'speaker', 'custom'). type can not be 'sentence' as that is (e.g., 'code', 'speaker', 'custom'). type can not be 'sentence' or 'word' as
reserved for the default behavior. those are reserved for the default behavior.
start_pattern: Pattern that marks the beginning of content. start_pattern: Pattern that marks the beginning of content.
end_pattern: Pattern that marks the end of content. end_pattern: Pattern that marks the end of content.
action: What to do when a complete pattern is matched: action: What to do when a complete pattern is matched:
@@ -143,9 +143,9 @@ class PatternPairAggregator(BaseTextAggregator):
Returns: Returns:
Self for method chaining. Self for method chaining.
""" """
if type == "sentence": if type in [AggregationType.SENTENCE, AggregationType.WORD]:
raise ValueError( raise ValueError(
"The aggregation type 'sentence' is reserved for default behavior and can not be used for custom patterns." f"The aggregation type '{type}' is reserved for default behavior and can not be used for custom patterns."
) )
self._patterns[type] = { self._patterns[type] = {
"start": start_pattern, "start": start_pattern,
@@ -169,8 +169,8 @@ class PatternPairAggregator(BaseTextAggregator):
Args: Args:
pattern_id: Identifier for this pattern pair. Should be unique and ideally descriptive. pattern_id: Identifier for this pattern pair. Should be unique and ideally descriptive.
(e.g., 'code', 'speaker', 'custom'). pattern_id can not be 'sentence' as that is (e.g., 'code', 'speaker', 'custom'). pattern_id can not be 'sentence' or 'word'
reserved for the default behavior. as those arereserved for the default behavior.
start_pattern: Pattern that marks the beginning of content. start_pattern: Pattern that marks the beginning of content.
end_pattern: Pattern that marks the end of content. end_pattern: Pattern that marks the end of content.
remove_match: If True, the matched pattern will be removed from the text. (Same as MatchAction.REMOVE) remove_match: If True, the matched pattern will be removed from the text. (Same as MatchAction.REMOVE)
@@ -345,7 +345,7 @@ class PatternPairAggregator(BaseTextAggregator):
# Otherwise, strip the text up to the start pattern and return it # Otherwise, strip the text up to the start pattern and return it
result = self._text[: pattern_start[0]] result = self._text[: pattern_start[0]]
self._text = self._text[pattern_start[0] :] self._text = self._text[pattern_start[0] :]
return PatternMatch(content=result, type="sentence", full_match=result) return PatternMatch(content=result, type=AggregationType.SENTENCE, full_match=result)
# Find sentence boundary if no incomplete patterns # Find sentence boundary if no incomplete patterns
eos_marker = match_endofsentence(self._text) eos_marker = match_endofsentence(self._text)
@@ -353,7 +353,7 @@ class PatternPairAggregator(BaseTextAggregator):
# Extract text up to the sentence boundary # Extract text up to the sentence boundary
result = self._text[:eos_marker] result = self._text[:eos_marker]
self._text = self._text[eos_marker:] self._text = self._text[eos_marker:]
return PatternMatch(content=result, type="sentence", full_match=result) return PatternMatch(content=result, type=AggregationType.SENTENCE, full_match=result)
# No complete sentence found yet # No complete sentence found yet
return None return None

View File

@@ -14,7 +14,7 @@ text processing scenarios.
from typing import Optional from typing import Optional
from pipecat.utils.string import match_endofsentence from pipecat.utils.string import match_endofsentence
from pipecat.utils.text.base_text_aggregator import Aggregation, BaseTextAggregator from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator
class SimpleTextAggregator(BaseTextAggregator): class SimpleTextAggregator(BaseTextAggregator):
@@ -39,7 +39,7 @@ class SimpleTextAggregator(BaseTextAggregator):
Returns: Returns:
The text that has been accumulated in the buffer. The text that has been accumulated in the buffer.
""" """
return Aggregation(self._text, "sentence") return Aggregation(self._text, AggregationType.SENTENCE)
async def aggregate(self, text: str) -> Optional[Aggregation]: async def aggregate(self, text: str) -> Optional[Aggregation]:
"""Aggregate text and return completed sentences. """Aggregate text and return completed sentences.
@@ -64,7 +64,7 @@ class SimpleTextAggregator(BaseTextAggregator):
result = self._text[:eos_end_marker] result = self._text[:eos_end_marker]
self._text = self._text[eos_end_marker:] self._text = self._text[eos_end_marker:]
return Aggregation(result, "sentence") if result else None return Aggregation(result, AggregationType.SENTENCE) if result else None
async def handle_interruption(self): async def handle_interruption(self):
"""Handle interruptions by clearing the text buffer. """Handle interruptions by clearing the text buffer.

View File

@@ -14,7 +14,7 @@ as a unit regardless of internal punctuation.
from typing import Optional, Sequence from typing import Optional, Sequence
from pipecat.utils.string import StartEndTags, match_endofsentence, parse_start_end_tags from pipecat.utils.string import StartEndTags, match_endofsentence, parse_start_end_tags
from pipecat.utils.text.base_text_aggregator import Aggregation, BaseTextAggregator from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator
class SkipTagsAggregator(BaseTextAggregator): class SkipTagsAggregator(BaseTextAggregator):
@@ -49,7 +49,7 @@ class SkipTagsAggregator(BaseTextAggregator):
Returns: Returns:
The current text buffer content that hasn't been processed yet. The current text buffer content that hasn't been processed yet.
""" """
return Aggregation(self._text, "sentence") return Aggregation(self._text, AggregationType.SENTENCE)
async def aggregate(self, text: str) -> Optional[Aggregation]: async def aggregate(self, text: str) -> Optional[Aggregation]:
"""Aggregate text while respecting tag boundaries. """Aggregate text while respecting tag boundaries.
@@ -80,7 +80,7 @@ class SkipTagsAggregator(BaseTextAggregator):
# Extract text up to the sentence boundary # Extract text up to the sentence boundary
result = self._text[:eos_marker] result = self._text[:eos_marker]
self._text = self._text[eos_marker:] self._text = self._text[eos_marker:]
return Aggregation(result, "sentence") return Aggregation(result, AggregationType.SENTENCE)
# No complete sentence found yet # No complete sentence found yet
return None return None