Merge pull request #3132 from pipecat-ai/mb/normalize-llm-text-frame-output

Add split_text_by_spaces string util, normalize aggregator input
This commit is contained in:
Mark Backman
2025-12-03 22:05:14 -05:00
committed by GitHub
13 changed files with 456 additions and 244 deletions

View File

@@ -15,6 +15,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved interruption handling to prevent bots from repeating themselves.
LLM services that return multiple sentences in a single response (e.g.,
`GoogleLLMService`) are now split into individual sentences before being sent
to TTS. This ensures interruptions occur at sentence boundaries, preventing
the bot from repeating content after being interrupted during long responses.
- Text Aggregation Improvements:
- **Breaking Change**: `BaseTextAggregator.aggregate()` now returns
`AsyncIterator[Aggregation]` instead of `Optional[Aggregation]`. This
enables the aggregator to return multiple results based on the provided
text.
- Refactored text aggregators to use inheritance: `SkipTagsAggregator` and
`PatternPairAggregator` now inherit from `SimpleTextAggregator`, reusing
the base class's sentence detection logic.
- Updated `AICFilter` to use Quail STT as the default model - Updated `AICFilter` to use Quail STT as the default model
(`AICModelType.QUAIL_STT`). Quail STT is optimized for human-to-machine (`AICModelType.QUAIL_STT`). Quail STT is optimized for human-to-machine
interaction (e.g., voice agents, speech-to-text) and operates at a native interaction (e.g., voice agents, speech-to-text) and operates at a native
@@ -41,6 +57,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where `LLMTextFrame.skip_tts` was being overwritten by LLM - Fixed an issue where `LLMTextFrame.skip_tts` was being overwritten by LLM
services. services.
- Fixed sentence aggregation to correctly handle ambiguous punctuation in
streaming text, such as currency ("$29.95") and abbreviations ("Mr. Smith").
- Fixed bug in `PatternPairAggregator` where pattern handlers could be called
multiple times for `KEEP` or `AGGREGATE` patterns.
- Fixed an issue in `SarvamTTSService` where the last sentence was not being - Fixed an issue in `SarvamTTSService` where the last sentence was not being
spoken. Now, audio is flushed when the TTS services receives the spoken. Now, audio is flushed when the TTS services receives the
`LLMFullResponseEndFrame` or `EndFrame`. `LLMFullResponseEndFrame` or `EndFrame`.

View File

@@ -18,8 +18,10 @@ from loguru import logger
from pipecat.audio.dtmf.types import KeypadEntry from pipecat.audio.dtmf.types import KeypadEntry
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import ( from pipecat.frames.frames import (
EndFrame,
Frame, Frame,
LLMContextFrame, LLMContextFrame,
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame, LLMMessagesUpdateFrame,
LLMTextFrame, LLMTextFrame,
OutputDTMFUrgentFrame, OutputDTMFUrgentFrame,
@@ -149,11 +151,18 @@ class IVRProcessor(FrameProcessor):
elif isinstance(frame, LLMTextFrame): elif isinstance(frame, LLMTextFrame):
# Process text through the pattern aggregator # Process text through the pattern aggregator
result = await self._aggregator.aggregate(frame.text) async for result in self._aggregator.aggregate(frame.text):
if result:
# Push aggregated text that doesn't contain XML patterns # Push aggregated text that doesn't contain XML patterns
await self.push_frame(LLMTextFrame(result.text), direction) await self.push_frame(LLMTextFrame(result.text), direction)
elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
# Flush any remaining text from the aggregator
remaining = await self._aggregator.flush()
if remaining:
await self.push_frame(LLMTextFrame(remaining.text), direction)
# Push the end frame
await self.push_frame(frame, direction)
else: else:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)

View File

@@ -83,8 +83,7 @@ class LLMTextProcessor(FrameProcessor):
await self._text_aggregator.reset() await self._text_aggregator.reset()
async def _handle_llm_text(self, in_frame: LLMTextFrame): async def _handle_llm_text(self, in_frame: LLMTextFrame):
aggregation = await self._text_aggregator.aggregate(in_frame.text) async for aggregation in self._text_aggregator.aggregate(in_frame.text):
if aggregation:
out_frame = AggregatedTextFrame( out_frame = AggregatedTextFrame(
text=aggregation.text, text=aggregation.text,
aggregated_by=aggregation.type, aggregated_by=aggregation.type,
@@ -93,14 +92,12 @@ class LLMTextProcessor(FrameProcessor):
await self.push_frame(out_frame) await self.push_frame(out_frame)
async def _handle_llm_end(self, skip_tts: Optional[bool] = None): async def _handle_llm_end(self, skip_tts: Optional[bool] = None):
# Flush any remaining aggregated text at the end of the LLM response # Flush any remaining text
aggregation = self._text_aggregator.text remaining = await self._text_aggregator.flush()
await self._text_aggregator.reset() if remaining:
text = aggregation.text.strip()
if text:
out_frame = AggregatedTextFrame( out_frame = AggregatedTextFrame(
text=text, text=remaining.text,
aggregated_by=aggregation.type, aggregated_by=remaining.type,
) )
out_frame.skip_tts = skip_tts out_frame.skip_tts = skip_tts
await self.push_frame(out_frame) await self.push_frame(out_frame)

View File

@@ -425,16 +425,13 @@ 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()
pending_aggregation = self._text_aggregator.text # Flush any remaining text (including text waiting for lookahead)
remaining = await self._text_aggregator.flush()
if remaining:
await self._push_tts_frames(AggregatedTextFrame(remaining.text, remaining.type))
# Reset aggregator state # Reset aggregator state
await self._text_aggregator.reset()
self._processing_text = False self._processing_text = False
if pending_aggregation.text:
await self._push_tts_frames(
AggregatedTextFrame(pending_aggregation.text, pending_aggregation.type)
)
if isinstance(frame, LLMFullResponseEndFrame): if isinstance(frame, LLMFullResponseEndFrame):
if self._push_text_frames: if self._push_text_frames:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -539,17 +536,20 @@ class TTSService(AIService):
text = frame.text text = frame.text
includes_inter_frame_spaces = frame.includes_inter_frame_spaces includes_inter_frame_spaces = frame.includes_inter_frame_spaces
aggregated_by = "token" aggregated_by = "token"
if text:
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
await self._push_tts_frames(
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
)
else: else:
aggregate = await self._text_aggregator.aggregate(frame.text) async for aggregate in self._text_aggregator.aggregate(frame.text):
if aggregate:
text = aggregate.text text = aggregate.text
aggregated_by = aggregate.type aggregated_by = aggregate.type
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
if text: await self._push_tts_frames(
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}") AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
await self._push_tts_frames( )
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
)
async def _push_tts_frames( async def _push_tts_frames(
self, src_frame: AggregatedTextFrame, includes_inter_frame_spaces: Optional[bool] = False self, src_frame: AggregatedTextFrame, includes_inter_frame_spaces: Optional[bool] = False

View File

@@ -14,7 +14,7 @@ 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 enum import Enum
from typing import Optional from typing import AsyncIterator, Optional
class AggregationType(str, Enum): class AggregationType(str, Enum):
@@ -80,33 +80,43 @@ class BaseTextAggregator(ABC):
pass pass
@abstractmethod @abstractmethod
async def aggregate(self, text: str) -> Optional[Aggregation]: async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate the specified text with the currently accumulated text. """Aggregate the specified text and yield completed aggregations.
This method should be implemented to define how the new text contributes This method processes the input text character-by-character internally
to the aggregation process. It returns the aggregated text and a string and yields Aggregation objects as they complete.
describing how it was aggregated if it's ready to be processed,
or None otherwise.
Subclasses should implement their specific logic for: Subclasses should implement their specific logic for:
- How to combine new text with existing accumulated text - How to process text character-by-character
- When to consider the aggregated text ready for processing - When to consider the aggregated text ready for processing
- What criteria determine text completion (e.g., sentence boundaries) - What criteria determine text completion (e.g., sentence boundaries)
- When a completion occurs, the method should return an Aggregation object - When a completion occurs, yield an Aggregation object containing the
containing the aggregated text and its type. The text should be stripped aggregated text (stripped of leading/trailing whitespace) and its type
of leading/trailing whitespace so that consumers can rely on a consistent
format.
Args: Args:
text: The text to be aggregated. text: The text to be aggregated.
Yields:
Aggregation objects as they complete. Each Aggregation consists of
the aggregated text (stripped of leading/trailing whitespace) and
a string indicating the type of aggregation (e.g., 'sentence', 'word',
'token', 'my_custom_aggregation').
"""
pass
# Make this a generator to satisfy type checker
yield # pragma: no cover
@abstractmethod
async def flush(self) -> Optional[Aggregation]:
"""Flush any pending aggregation.
This method is called at the end of a stream (e.g., when receiving
LLMFullResponseEndFrame) to return any text that was buffered.
Returns: Returns:
An Aggregation object if ready for processing, or None if more An Aggregation object if there is pending text, or None if there
text is needed before the aggregated content is ready. If an Aggregation is no pending text.
object is returned, it should consist of the updated aggregated text,
stripped of leading/trailing whitespace, and a string indicating the
type of aggregation (e.g., 'sentence', 'word', 'token', 'my_custom_aggregation').
""" """
pass pass

View File

@@ -13,12 +13,12 @@ support for custom handlers and configurable actions for when a pattern is found
import re import re
from enum import Enum from enum import Enum
from typing import Awaitable, Callable, List, Optional, Tuple from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple
from loguru import logger from loguru import logger
from pipecat.utils.string import match_endofsentence from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
class MatchAction(Enum): class MatchAction(Enum):
@@ -72,7 +72,7 @@ class PatternMatch(Aggregation):
return f"PatternMatch(type={self.type}, text={self.text}, full_match={self.full_match})" return f"PatternMatch(type={self.type}, text={self.text}, full_match={self.full_match})"
class PatternPairAggregator(BaseTextAggregator): class PatternPairAggregator(SimpleTextAggregator):
"""Aggregator that identifies and processes content between pattern pairs. """Aggregator that identifies and processes content between pattern pairs.
This aggregator buffers text until it can identify complete pattern pairs This aggregator buffers text until it can identify complete pattern pairs
@@ -97,9 +97,10 @@ class PatternPairAggregator(BaseTextAggregator):
Creates an empty aggregator with no patterns or handlers registered. Creates an empty aggregator with no patterns or handlers registered.
Text buffering and pattern detection will begin when text is aggregated. Text buffering and pattern detection will begin when text is aggregated.
""" """
self._text = "" super().__init__()
self._patterns = {} self._patterns = {}
self._handlers = {} self._handlers = {}
self._last_processed_position = 0 # Track where we last checked for complete patterns
@property @property
def text(self) -> Aggregation: def text(self) -> Aggregation:
@@ -218,14 +219,18 @@ class PatternPairAggregator(BaseTextAggregator):
self._handlers[type] = handler self._handlers[type] = handler
return self return self
async def _process_complete_patterns(self, text: str) -> Tuple[List[PatternMatch], str]: async def _process_complete_patterns(
"""Process all complete pattern pairs in the text. self, text: str, last_processed_position: int = 0
) -> Tuple[List[PatternMatch], str]:
"""Process newly complete pattern pairs in the text.
Searches for all complete pattern pairs in the text, calls the Searches for pattern pairs that have been completed since last_processed_position,
appropriate handlers, and optionally removes the matches. calls the appropriate handlers, and optionally removes the matches.
Args: Args:
text: The text to process for pattern matches. text: The text to process for pattern matches.
last_processed_position: The position in text that was already processed.
Only patterns that end at or after this position will be processed.
Returns: Returns:
Tuple of (all_matches, processed_text) where: Tuple of (all_matches, processed_text) where:
@@ -259,17 +264,23 @@ class PatternPairAggregator(BaseTextAggregator):
content=content.strip(), type=type, full_match=full_match content=content.strip(), type=type, full_match=full_match
) )
# Call the appropriate handler if registered # Check if this pattern was already processed
if type in self._handlers: already_processed = match.end() <= last_processed_position
# Only call handler for newly completed patterns
if not already_processed and type in self._handlers:
try: try:
await self._handlers[type](pattern_match) await self._handlers[type](pattern_match)
except Exception as e: except Exception as e:
logger.error(f"Error in pattern handler for {type}: {e}") logger.error(f"Error in pattern handler for {type}: {e}")
# Remove the pattern from the text if configured # Handle pattern based on action
if action == MatchAction.REMOVE: if action == MatchAction.REMOVE:
processed_text = processed_text.replace(full_match, "", 1) # Remove patterns are only removed once (when newly completed)
if not already_processed:
processed_text = processed_text.replace(full_match, "", 1)
else: else:
# KEEP/AGGREGATE patterns stay in all_matches
all_matches.append(pattern_match) all_matches.append(pattern_match)
return all_matches, processed_text return all_matches, processed_text
@@ -305,76 +316,84 @@ class PatternPairAggregator(BaseTextAggregator):
return None return None
async def aggregate(self, text: str) -> Optional[PatternMatch]: async def aggregate(self, text: str) -> AsyncIterator[PatternMatch]:
"""Aggregate text and process pattern pairs. """Aggregate text and process pattern pairs.
This method adds the new text to the buffer, processes any complete pattern Processes the input text character-by-character, handles pattern pairs,
pairs, and returns processed text up to sentence boundaries if possible. and uses the parent's lookahead logic for sentence detection when no
If there are incomplete patterns (start without matching end), it will patterns are active.
continue buffering text.
Args: Args:
text: New text to add to the buffer. text: Text to aggregate.
Returns: Yields:
Processed text up to a sentence boundary, or None if more PatternMatch objects as patterns complete or sentences are detected.
text is needed to form a complete sentence or pattern.
""" """
# Add new text to buffer # Process text character by character
self._text += text for char in text:
self._text += char
# Process any complete patterns in the buffer # Process any newly complete patterns in the buffer
patterns, processed_text = await self._process_complete_patterns(self._text) # Only patterns that complete after _last_processed_position will trigger handlers
patterns, processed_text = await self._process_complete_patterns(
self._text, self._last_processed_position
)
self._text = processed_text # Update the last processed position to prevent re-processing patterns
# This tracks where in the buffer we've already called handlers, so we
# only trigger handlers once when a pattern completes
self._last_processed_position = len(self._text)
if len(patterns) > 0: self._text = processed_text
if len(patterns) > 1:
logger.warning( if len(patterns) > 0:
f"Multiple patterns matched: {[p.type for p in patterns]}. Only the first pattern will be returned." if len(patterns) > 1:
logger.warning(
f"Multiple patterns matched: {[p.type for p in patterns]}. Only the first pattern will be returned."
)
# If the pattern found is set to be aggregated, return it
action = self._patterns[patterns[0].type].get("action", MatchAction.REMOVE)
if action == MatchAction.AGGREGATE:
self._text = ""
yield patterns[0]
continue
# Check if we have incomplete patterns
pattern_start = self._match_start_of_pattern(self._text)
if pattern_start is not None:
# If the start pattern is at the beginning or should not be separately aggregated, continue
if (
pattern_start[0] == 0
or pattern_start[1].get("action", MatchAction.REMOVE) != MatchAction.AGGREGATE
):
continue
# For AGGREGATE patterns: yield any text before the pattern starts
# This ensures text doesn't get stuck in the buffer waiting for sentence
# boundaries when a pattern begins (e.g., "Here is code <code>..." yields "Here is code")
result = self._text[: pattern_start[0]]
self._text = self._text[pattern_start[0] :]
yield PatternMatch(
content=result.strip(), type=AggregationType.SENTENCE, full_match=result
) )
# If the pattern found is set to be aggregated, return it continue
action = self._patterns[patterns[0].type].get("action", MatchAction.REMOVE)
if action == MatchAction.AGGREGATE:
self._text = ""
return patterns[0]
# Check if we have incomplete patterns # Use parent's lookahead logic for sentence detection
pattern_start = self._match_start_of_pattern(self._text) aggregation = await super()._check_sentence_with_lookahead(char)
if pattern_start is not None: if aggregation:
# If the start pattern is at the beginning or should not be separately aggregated, return None # Convert to PatternMatch for consistency with return type
if ( yield PatternMatch(
pattern_start[0] == 0 content=aggregation.text, type=aggregation.type, full_match=aggregation.text
or pattern_start[1].get("action", MatchAction.REMOVE) != MatchAction.AGGREGATE )
):
return None
# Otherwise, strip the text up to the start pattern and return it
result = self._text[: pattern_start[0]]
self._text = self._text[pattern_start[0] :]
return PatternMatch(
content=result.strip(), type=AggregationType.SENTENCE, full_match=result
)
# Find sentence boundary if no incomplete patterns
eos_marker = match_endofsentence(self._text)
if eos_marker:
# Extract text up to the sentence boundary
result = self._text[:eos_marker]
self._text = self._text[eos_marker:]
return PatternMatch(
content=result.strip(), type=AggregationType.SENTENCE, full_match=result
)
# No complete sentence found yet
return None
async def handle_interruption(self): async def handle_interruption(self):
"""Handle interruptions by clearing the buffer. """Handle interruptions by clearing the buffer and pattern state.
Called when an interruption occurs in the processing pipeline, Called when an interruption occurs in the processing pipeline,
to reset the state and discard any partially aggregated text. to reset the state and discard any partially aggregated text.
""" """
self._text = "" await super().handle_interruption()
self._last_processed_position = 0
# Pattern and handler state persists across interruptions
async def reset(self): async def reset(self):
"""Clear the internally aggregated text. """Clear the internally aggregated text.
@@ -382,4 +401,6 @@ class PatternPairAggregator(BaseTextAggregator):
Resets the aggregator to its initial state, discarding any Resets the aggregator to its initial state, discarding any
buffered text and clearing pattern tracking state. buffered text and clearing pattern tracking state.
""" """
self._text = "" await super().reset()
self._last_processed_position = 0
# Pattern and handler state persists across resets

View File

@@ -11,9 +11,9 @@ until it finds an end-of-sentence marker, making it suitable for basic TTS
text processing scenarios. text processing scenarios.
""" """
from typing import Optional from typing import AsyncIterator, Optional
from pipecat.utils.string import match_endofsentence from pipecat.utils.string import SENTENCE_ENDING_PUNCTUATION, match_endofsentence
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator
@@ -31,6 +31,7 @@ class SimpleTextAggregator(BaseTextAggregator):
Creates an empty text buffer ready to begin accumulating text tokens. Creates an empty text buffer ready to begin accumulating text tokens.
""" """
self._text = "" self._text = ""
self._needs_lookahead: bool = False
@property @property
def text(self) -> Aggregation: def text(self) -> Aggregation:
@@ -41,30 +42,87 @@ class SimpleTextAggregator(BaseTextAggregator):
""" """
return Aggregation(text=self._text.strip(), type=AggregationType.SENTENCE) return Aggregation(text=self._text.strip(), type=AggregationType.SENTENCE)
async def aggregate(self, text: str) -> Optional[Aggregation]: async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate text and return completed sentences. """Aggregate text and yield completed sentences.
Adds the new text to the buffer and checks for end-of-sentence markers. Processes the input text character-by-character. When sentence-ending
When a sentence boundary is found, returns the completed sentence and punctuation is detected, it waits for non-whitespace lookahead before
removes it from the buffer. calling NLTK. This prevents false positives like "$29." being detected
as a sentence when it's actually "$29.95".
Args: Args:
text: New text to add to the aggregation buffer. text: Text to aggregate.
Yields:
Complete sentences as Aggregation objects.
"""
# Process text character by character
for char in text:
self._text += char
# Check for sentence with lookahead
result = await self._check_sentence_with_lookahead(char)
if result:
yield result
async def _check_sentence_with_lookahead(self, char: str) -> Optional[Aggregation]:
"""Check for sentence boundaries using lookahead logic.
This method implements the core sentence detection logic with lookahead.
When sentence-ending punctuation is detected, it waits for the next
non-whitespace character before calling NLTK. This disambiguates cases
like "$29." (not a sentence) vs "$29. Next" (sentence ends at period).
Whitespace alone is not meaningful lookahead since it appears in both
cases. Instead, the first non-whitespace character after the punctuation
is used to confirm the sentence boundary.
Subclasses can call this via super() to reuse the lookahead behavior
while adding their own logic (e.g., tag handling, pattern matching).
Args:
char: The most recently added character (used for lookahead check).
Returns: Returns:
A complete sentence if an end-of-sentence marker is found, Aggregation if sentence found, None otherwise.
or None if more text is needed to complete a sentence.
""" """
result: Optional[str] = None # If we need lookahead, check if we now have non-whitespace
if self._needs_lookahead:
# Check if the new character is non-whitespace
if char.strip():
# We have meaningful lookahead, call NLTK
self._needs_lookahead = False
eos_marker = match_endofsentence(self._text)
self._text += text if eos_marker:
# NLTK confirmed a sentence - return it
result = self._text[:eos_marker]
self._text = self._text[eos_marker:]
return Aggregation(text=result, type=AggregationType.SENTENCE)
# No sentence found - keep accumulating
return None
# Still whitespace, keep waiting
return None
eos_end_marker = match_endofsentence(self._text) # Check if we just added sentence-ending punctuation
if eos_end_marker: if self._text and self._text[-1] in SENTENCE_ENDING_PUNCTUATION:
result = self._text[:eos_end_marker] # Mark that we need lookahead (don't call NLTK yet)
self._text = self._text[eos_end_marker:] self._needs_lookahead = True
if result: return None
async def flush(self) -> Optional[Aggregation]:
"""Flush any remaining text in the buffer.
Returns any text remaining in the buffer. This is called at the end
of a stream to ensure all text is processed.
Returns:
Any remaining text as a sentence, or None if buffer is empty.
"""
if self._text:
# Return whatever we have in the buffer
result = self._text
await self.reset()
return Aggregation(text=result.strip(), type=AggregationType.SENTENCE) return Aggregation(text=result.strip(), type=AggregationType.SENTENCE)
return None return None
@@ -75,6 +133,7 @@ class SimpleTextAggregator(BaseTextAggregator):
discarding any partially accumulated text. discarding any partially accumulated text.
""" """
self._text = "" self._text = ""
self._needs_lookahead = False
async def reset(self): async def reset(self):
"""Clear the internally aggregated text. """Clear the internally aggregated text.
@@ -83,3 +142,4 @@ class SimpleTextAggregator(BaseTextAggregator):
any accumulated text content. any accumulated text content.
""" """
self._text = "" self._text = ""
self._needs_lookahead = False

View File

@@ -11,13 +11,14 @@ between specified start/end tag pairs, ensuring that tagged content is processed
as a unit regardless of internal punctuation. as a unit regardless of internal punctuation.
""" """
from typing import Optional, Sequence from typing import AsyncIterator, Optional, Sequence
from pipecat.utils.string import StartEndTags, match_endofsentence, parse_start_end_tags from pipecat.utils.string import StartEndTags, parse_start_end_tags
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
class SkipTagsAggregator(BaseTextAggregator): class SkipTagsAggregator(SimpleTextAggregator):
"""Aggregator that prevents end of sentence matching between start/end tags. """Aggregator that prevents end of sentence matching between start/end tags.
This aggregator buffers text until it finds an end of sentence or a start This aggregator buffers text until it finds an end of sentence or a start
@@ -37,67 +38,59 @@ class SkipTagsAggregator(BaseTextAggregator):
tags: Sequence of StartEndTags objects defining the tag pairs tags: Sequence of StartEndTags objects defining the tag pairs
that should prevent sentence boundary detection. that should prevent sentence boundary detection.
""" """
self._text = "" super().__init__()
self._tags = tags self._tags = tags
self._current_tag: Optional[StartEndTags] = None self._current_tag: Optional[StartEndTags] = None
self._current_tag_index: int = 0 self._current_tag_index: int = 0
@property async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
def text(self) -> Aggregation:
"""Get the currently buffered text.
Returns:
The current text buffer content that hasn't been processed yet.
"""
return Aggregation(text=self._text.strip(), type=AggregationType.SENTENCE)
async def aggregate(self, text: str) -> Optional[Aggregation]:
"""Aggregate text while respecting tag boundaries. """Aggregate text while respecting tag boundaries.
This method adds the new text to the buffer, processes any complete Processes the input text character-by-character, updates tag state, and
pattern pairs, and returns processed text up to sentence boundaries if uses the parent's lookahead logic for sentence detection when not
possible. If there are incomplete patterns (start without matching inside tags.
end), it will continue buffering text.
Args: Args:
text: New text to add to the buffer. text: Text to aggregate.
Returns: Yields:
An Aggregation object containing text up to a sentence boundary and Aggregation objects containing text up to a sentence boundary,
marked as SENTENCE type or None if more text is needed to complete a marked as SENTENCE type.
sentence or close tags.
""" """
# Add new text to buffer # Process text character by character
self._text += text for char in text:
self._text += char
(self._current_tag, self._current_tag_index) = parse_start_end_tags( # Update tag state
self._text, self._tags, self._current_tag, self._current_tag_index (self._current_tag, self._current_tag_index) = parse_start_end_tags(
) self._text, self._tags, self._current_tag, self._current_tag_index
)
# Find sentence boundary if no incomplete patterns # If inside tags, don't check for sentences
if not self._current_tag: if self._current_tag:
eos_marker = match_endofsentence(self._text) continue
if eos_marker:
# Extract text up to the sentence boundary
result = self._text[:eos_marker]
self._text = self._text[eos_marker:]
return Aggregation(text=result.strip(), type=AggregationType.SENTENCE)
# No complete sentence found yet # Otherwise, use parent's lookahead logic for sentence detection
return None result = await super()._check_sentence_with_lookahead(char)
if result:
yield result
async def handle_interruption(self): async def handle_interruption(self):
"""Handle interruptions by clearing the buffer. """Handle interruptions by clearing the buffer and tag state.
Called when an interruption occurs in the processing pipeline, Called when an interruption occurs in the processing pipeline,
to reset the state and discard any partially aggregated text. to reset the state and discard any partially aggregated text.
""" """
self._text = "" await super().handle_interruption()
self._current_tag = None
self._current_tag_index = 0
async def reset(self): async def reset(self):
"""Clear the internally aggregated text. """Clear the internally aggregated text and tag state.
Resets the aggregator to its initial state, discarding any Resets the aggregator to its initial state, discarding any
buffered text. buffered text.
""" """
self._text = "" await super().reset()
self._current_tag = None
self._current_tag_index = 0

View File

@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.extensions.ivr.ivr_navigator import IVRProcessor from pipecat.extensions.ivr.ivr_navigator import IVRProcessor
from pipecat.frames.frames import ( from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame, LLMMessagesUpdateFrame,
LLMTextFrame, LLMTextFrame,
OutputDTMFUrgentFrame, OutputDTMFUrgentFrame,
@@ -334,10 +335,12 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
frames_to_send = [ frames_to_send = [
LLMTextFrame(text="Hello, I'm trying to reach billing."), LLMTextFrame(text="Hello, I'm trying to reach billing."),
LLMFullResponseEndFrame(),
] ]
expected_down_frames = [ expected_down_frames = [
LLMTextFrame, # Should pass through unchanged LLMTextFrame, # Should pass through unchanged
LLMFullResponseEndFrame,
] ]
expected_up_frames = [ expected_up_frames = [

View File

@@ -38,14 +38,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator.on_pattern_match("code_pattern", self.code_handler) self.aggregator.on_pattern_match("code_pattern", self.code_handler)
async def test_pattern_match_and_removal(self): async def test_pattern_match_and_removal(self):
# First part doesn't complete the pattern text = "Hello <test>pattern content</test>!"
result = await self.aggregator.aggregate("Hello <test>pattern") results = [result async for result in self.aggregator.aggregate(text)]
self.assertIsNone(result)
self.assertEqual(self.aggregator.text.text, "Hello <test>pattern")
self.assertEqual(self.aggregator.text.type, "test_pattern")
# Second part completes the pattern and includes an exclamation point
result = await self.aggregator.aggregate(" content</test>!")
# Verify the handler was called with correct PatternMatch object # Verify the handler was called with correct PatternMatch object
self.test_handler.assert_called_once() self.test_handler.assert_called_once()
@@ -55,28 +49,37 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(call_args.full_match, "<test>pattern content</test>") self.assertEqual(call_args.full_match, "<test>pattern content</test>")
self.assertEqual(call_args.text, "pattern content") self.assertEqual(call_args.text, "pattern content")
# The exclamation point should be treated as a sentence boundary, # No results yet (waiting for lookahead after "!")
# so the result should include just text up to and including "!" self.assertEqual(len(results), 0)
self.assertEqual(result.text, "Hello !")
self.assertEqual(result.type, "sentence")
# Next sentence should be processed separately. Spaces around the sentence # Next sentence should provide the lookahead and trigger the previous sentence
# should be stripped in the returned Aggregation. async for result in self.aggregator.aggregate(" This is another sentence."):
result = await self.aggregator.aggregate(" This is another sentence.") results.append(result)
# First result should be "Hello !" triggered by the space lookahead
self.assertEqual(len(results), 1)
self.assertEqual(results[0].text, "Hello !")
self.assertEqual(results[0].type, "sentence")
# Now flush to get the remaining sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "This is another sentence.") self.assertEqual(result.text, "This is another sentence.")
# Buffer should be empty after returning a complete sentence # Buffer should be empty after returning a complete sentence
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
async def test_pattern_match_and_aggregate(self): async def test_pattern_match_and_aggregate(self):
# First part doesn't complete the pattern text = "Here is code <code>pattern content</code> This is another sentence."
result = await self.aggregator.aggregate("Here is code <code>pattern")
self.assertEqual(result.text, "Here is code")
self.assertEqual(self.aggregator.text.text, "<code>pattern")
self.assertEqual(self.aggregator.text.type, "code_pattern")
# Second part completes the pattern and includes an exclamation point results = [result async for result in self.aggregator.aggregate(text)]
result = await self.aggregator.aggregate(" content</code>")
# First result should be "Here is code" when pattern starts
self.assertEqual(results[0].text, "Here is code")
self.assertEqual(results[0].type, "sentence")
# Second result should be the code pattern content
self.assertEqual(results[1].text, "pattern content")
self.assertEqual(results[1].type, "code_pattern")
# Verify the handler was called with correct PatternMatch object # Verify the handler was called with correct PatternMatch object
self.code_handler.assert_called_once() self.code_handler.assert_called_once()
@@ -85,11 +88,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(call_args.type, "code_pattern") self.assertEqual(call_args.type, "code_pattern")
self.assertEqual(call_args.full_match, "<code>pattern content</code>") self.assertEqual(call_args.full_match, "<code>pattern content</code>")
self.assertEqual(call_args.text, "pattern content") self.assertEqual(call_args.text, "pattern content")
self.assertEqual(result.text, "pattern content")
self.assertEqual(result.type, "code_pattern")
# Next sentence should be processed separately # Last sentence needs flush (waiting for lookahead after ".")
result = await self.aggregator.aggregate(" This is another sentence.") result = await self.aggregator.flush()
self.assertEqual(result.text, "This is another sentence.") self.assertEqual(result.text, "This is another sentence.")
self.assertEqual(result.type, "sentence") self.assertEqual(result.type, "sentence")
@@ -97,11 +98,10 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
async def test_incomplete_pattern(self): async def test_incomplete_pattern(self):
# Add text with incomplete pattern text = "Hello <test>pattern content"
result = await self.aggregator.aggregate("Hello <test>pattern content") results = [result async for result in self.aggregator.aggregate(text)]
# No complete pattern yet, so nothing should be returned # No complete pattern yet, so nothing should be returned
self.assertIsNone(result) self.assertEqual(len(results), 0)
# The handler should not be called yet # The handler should not be called yet
self.test_handler.assert_not_called() self.test_handler.assert_not_called()
@@ -136,9 +136,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator.on_pattern_match("voice", voice_handler) self.aggregator.on_pattern_match("voice", voice_handler)
self.aggregator.on_pattern_match("emphasis", emphasis_handler) self.aggregator.on_pattern_match("emphasis", emphasis_handler)
# Test with multiple patterns in one text block
text = "Hello <voice>female</voice> I am <em>very</em> excited to meet you!" text = "Hello <voice>female</voice> I am <em>very</em> excited to meet you!"
result = await self.aggregator.aggregate(text) results = [result async for result in self.aggregator.aggregate(text)]
# Both handlers should be called with correct data # Both handlers should be called with correct data
voice_handler.assert_called_once() voice_handler.assert_called_once()
@@ -151,6 +150,10 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(emphasis_match.type, "emphasis") self.assertEqual(emphasis_match.type, "emphasis")
self.assertEqual(emphasis_match.text, "very") self.assertEqual(emphasis_match.text, "very")
# With lookahead, we need to flush to get the final sentence
self.assertEqual(len(results), 0) # Waiting for lookahead after "!"
result = await self.aggregator.flush()
# Voice pattern should be removed, emphasis pattern should remain # Voice pattern should be removed, emphasis pattern should remain
self.assertEqual(result.text, "Hello I am <em>very</em> excited to meet you!") self.assertEqual(result.text, "Hello I am <em>very</em> excited to meet you!")
@@ -158,9 +161,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
async def test_handle_interruption(self): async def test_handle_interruption(self):
# Start with incomplete pattern text = "Hello <test>pattern"
result = await self.aggregator.aggregate("Hello <test>pattern") results = [result async for result in self.aggregator.aggregate(text)]
self.assertIsNone(result) self.assertEqual(len(results), 0)
# Simulate interruption # Simulate interruption
await self.aggregator.handle_interruption() await self.aggregator.handle_interruption()
@@ -172,20 +175,18 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.test_handler.assert_not_called() self.test_handler.assert_not_called()
async def test_pattern_across_sentences(self): async def test_pattern_across_sentences(self):
# Test pattern that spans multiple sentences text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence."
result = await self.aggregator.aggregate("Hello <test>This is sentence one.") results = [result async for result in self.aggregator.aggregate(text)]
# First sentence contains start of pattern but no end, so no complete pattern yet
self.assertIsNone(result)
# Add second part with pattern end
result = await self.aggregator.aggregate(" This is sentence two.</test> Final sentence.")
# Handler should be called with entire content # Handler should be called with entire content
self.test_handler.assert_called_once() self.test_handler.assert_called_once()
call_args = self.test_handler.call_args[0][0] call_args = self.test_handler.call_args[0][0]
self.assertEqual(call_args.text, "This is sentence one. This is sentence two.") self.assertEqual(call_args.text, "This is sentence one. This is sentence two.")
# With lookahead, we need to flush to get the final sentence
self.assertEqual(len(results), 0) # Waiting for lookahead after "."
result = await self.aggregator.flush()
# Pattern should be removed, resulting in text with sentences merged # Pattern should be removed, resulting in text with sentences merged
self.assertEqual(result.text, "Hello Final sentence.") self.assertEqual(result.text, "Hello Final sentence.")

View File

@@ -14,22 +14,112 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator = SimpleTextAggregator() self.aggregator = SimpleTextAggregator()
async def test_reset_aggregations(self): async def test_reset_aggregations(self):
assert await self.aggregator.aggregate("Hello ") == None text = "Hello "
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet
assert len(results) == 0
assert self.aggregator.text.text == "Hello" assert self.aggregator.text.text == "Hello"
await self.aggregator.reset() await self.aggregator.reset()
assert self.aggregator.text.text == "" assert self.aggregator.text.text == ""
async def test_simple_sentence(self): async def test_simple_sentence(self):
assert await self.aggregator.aggregate("Hello ") == None text = "Hello Pipecat!"
aggregate = await self.aggregator.aggregate("Pipecat!") results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after "!")
assert len(results) == 0
# Flush to get the pending sentence
aggregate = await self.aggregator.flush()
assert aggregate.text == "Hello Pipecat!" assert aggregate.text == "Hello Pipecat!"
assert aggregate.type == "sentence" assert aggregate.type == "sentence"
assert self.aggregator.text.text == "" assert self.aggregator.text.text == ""
async def test_multiple_sentences(self): async def test_multiple_sentences(self):
aggregate = await self.aggregator.aggregate("Hello Pipecat! How are ") text = "Hello Pipecat! How are you?"
assert aggregate.text == "Hello Pipecat!" results = [agg async for agg in self.aggregator.aggregate(text)]
# Aggregators should strip leading/trailing spaces when returning text
assert self.aggregator.text.text == "How are" # First sentence should be complete (lookahead from "H" confirmed it)
aggregate = await self.aggregator.aggregate("you?") assert len(results) == 1
assert aggregate.text == "How are you?" assert results[0].text == "Hello Pipecat!"
# Flush to get the pending sentence
result = await self.aggregator.flush()
assert result.text == "How are you?"
async def test_lookahead_decimal_number(self):
"""Test that $29.95 is not split at $29."""
text = "Ask me for only $29.95/month."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Ask me for only $29.95/month."
async def test_lookahead_abbreviation(self):
"""Test that Mr. Smith is not split at Mr."""
text = "Hello Mr. Smith."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Hello Mr. Smith."
async def test_lookahead_actual_sentence_end(self):
"""Test that a real sentence end is detected after lookahead."""
text = "Hello world. Next sentence"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "N" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello world."
async def test_flush_pending_sentence(self):
"""Test that flush() returns pending sentence waiting for lookahead."""
text = "Hello world."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead)
assert len(results) == 0
# Call flush to get it
result = await self.aggregator.flush()
assert result is not None
assert result.text == "Hello world."
# Flush again should return None
assert await self.aggregator.flush() == None
async def test_flush_with_no_pending(self):
"""Test that flush() returns any remaining text in buffer."""
text = "Hello"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences
assert len(results) == 0
result = await self.aggregator.flush()
# flush() now returns any remaining text, not just pending lookahead
assert result is not None
assert result.text == "Hello"
# Buffer should be empty after flush
assert self.aggregator.text.text == ""
async def test_flush_after_lookahead_confirmed(self):
"""Test flush after lookahead has already confirmed sentence."""
text = "Hello. W"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "W" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello."
# flush() returns any remaining text (the "W" in this case)
result = await self.aggregator.flush()
assert result.text == "W"

View File

@@ -17,7 +17,14 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset() await self.aggregator.reset()
# No tags involved, aggregate at end of sentence. # No tags involved, aggregate at end of sentence.
result = await self.aggregator.aggregate("Hello Pipecat!") text = "Hello Pipecat!"
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "!"
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "Hello Pipecat!") self.assertEqual(result.text, "Hello Pipecat!")
self.assertEqual(result.type, "sentence") self.assertEqual(result.type, "sentence")
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
@@ -26,7 +33,14 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset() await self.aggregator.reset()
# Tags involved, avoid aggregation during tags. # Tags involved, avoid aggregation during tags.
result = await self.aggregator.aggregate("My email is <spell>foo@pipecat.ai</spell>.") text = "My email is <spell>foo@pipecat.ai</spell>."
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "."
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, "My email is <spell>foo@pipecat.ai</spell>.") self.assertEqual(result.text, "My email is <spell>foo@pipecat.ai</spell>.")
self.assertEqual(result.type, "sentence") self.assertEqual(result.type, "sentence")
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
@@ -34,25 +48,17 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
async def test_streaming_tags(self): async def test_streaming_tags(self):
await self.aggregator.reset() await self.aggregator.reset()
# Tags involved, stream small chunk of texts. # Tags involved
result = await self.aggregator.aggregate("My email is <sp") text = "My email is <spell>foo.bar@pipecat.ai</spell>."
self.assertIsNone(result) results = [agg async for agg in self.aggregator.aggregate(text)]
self.assertEqual(self.aggregator.text.text, "My email is <sp")
result = await self.aggregator.aggregate("ell>foo.") # Should still be waiting for lookahead after "."
self.assertIsNone(result) self.assertEqual(len(results), 0)
self.assertEqual(self.aggregator.text.text, "My email is <spell>foo.") self.assertEqual(self.aggregator.text.text, text)
result = await self.aggregator.aggregate("bar@pipecat.")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text.text, "My email is <spell>foo.bar@pipecat.")
result = await self.aggregator.aggregate("ai</spe")
self.assertIsNone(result)
self.assertEqual(self.aggregator.text.text, "My email is <spell>foo.bar@pipecat.ai</spe")
self.assertEqual(self.aggregator.text.type, "sentence") self.assertEqual(self.aggregator.text.type, "sentence")
result = await self.aggregator.aggregate("ll>.") # Flush to get the pending sentence
self.assertEqual(result.text, "My email is <spell>foo.bar@pipecat.ai</spell>.") result = await self.aggregator.flush()
self.assertEqual(result.text, text)
self.assertEqual(self.aggregator.text.text, "") self.assertEqual(self.aggregator.text.text, "")
self.assertEqual(self.aggregator.text.type, "sentence") self.assertEqual(self.aggregator.text.type, "sentence")

6
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
@@ -4710,7 +4710,6 @@ requires-dist = [
{ name = "numba", specifier = "==0.61.2" }, { name = "numba", specifier = "==0.61.2" },
{ name = "numpy", specifier = ">=1.26.4,<3" }, { name = "numpy", specifier = ">=1.26.4,<3" },
{ name = "nvidia-riva-client", marker = "extra == 'nvidia'", specifier = "~=2.21.1" }, { name = "nvidia-riva-client", marker = "extra == 'nvidia'", specifier = "~=2.21.1" },
{ name = "nvidia-riva-client", marker = "extra == 'riva'", specifier = "~=2.21.1" },
{ name = "onnxruntime", marker = "extra == 'local-smart-turn-v3'", specifier = ">=1.20.1,<2" }, { name = "onnxruntime", marker = "extra == 'local-smart-turn-v3'", specifier = ">=1.20.1,<2" },
{ name = "onnxruntime", marker = "extra == 'silero'", specifier = ">=1.20.1,<2" }, { name = "onnxruntime", marker = "extra == 'silero'", specifier = ">=1.20.1,<2" },
{ name = "openai", specifier = ">=1.74.0,<3" }, { name = "openai", specifier = ">=1.74.0,<3" },
@@ -4721,6 +4720,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" }, { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" },
{ name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" }, { name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" },
{ name = "pillow", specifier = ">=11.1.0,<12" }, { name = "pillow", specifier = ">=11.1.0,<12" },
{ name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'aws'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'aws'" },
@@ -4771,7 +4771,7 @@ requires-dist = [
{ name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" }, { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" },
{ name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" }, { name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" },
] ]
provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "nim", "noisereduce", "openai", "openpipe", "openrouter", "perplexity", "playht", "qwen", "remote-smart-turn", "rime", "riva", "nvidia", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "openpipe", "openrouter", "perplexity", "playht", "qwen", "remote-smart-turn", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [