Make aggregate return an AsyncIterator, other clean up

This commit is contained in:
Mark Backman
2025-12-03 14:46:30 -05:00
parent fa8e7458e1
commit d79dd94019
15 changed files with 259 additions and 378 deletions

View File

@@ -15,18 +15,21 @@ 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: - Text Aggregation Improvements:
- Updated `LLMTextProcessor` and `TTSService` to normalize text input by - **Breaking Change**: `BaseTextAggregator.aggregate()` now returns
splitting into individual characters before aggregation. This ensures proper `AsyncIterator[Aggregation]` instead of `Optional[Aggregation]`. This
sentence boundary detection when LLMs return multiple sentences in a single enables the aggregator to return multiple results based on the provided
chunk (e.g., Google Gemini). text.
- All text aggregators now properly support character-by-character streaming
input.
- Refactored text aggregators to use inheritance: `SkipTagsAggregator` and - Refactored text aggregators to use inheritance: `SkipTagsAggregator` and
`PatternPairAggregator` now inherit from `SimpleTextAggregator`, reusing `PatternPairAggregator` now inherit from `SimpleTextAggregator`, reusing
its lookahead-based sentence detection logic via the base class's sentence detection logic.
`_check_sentence_with_lookahead()`.
- 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
@@ -54,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`.
@@ -66,10 +75,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
voice-ui-kit's conversational panel rending of the LLM output after a voice-ui-kit's conversational panel rending of the LLM output after a
function call. function call.
- Fixed a bug in `PatternPairAggregator` where pattern handlers could be called
multiple times for patterns with `MatchAction.KEEP` or `MatchAction.AGGREGATE`
actions.
## [0.0.96] - 2025-11-26 🦃 "Happy Thanksgiving!" 🦃 ## [0.0.96] - 2025-11-26 🦃 "Happy Thanksgiving!" 🦃
### Added ### Added

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

@@ -24,7 +24,6 @@ from pipecat.frames.frames import (
LLMTextFrame, LLMTextFrame,
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.string import split_text_by_characters
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
@@ -84,19 +83,13 @@ 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):
# Split text by characters to normalize LLM output into individual characters async for aggregation in self._text_aggregator.aggregate(in_frame.text):
# This ensures consistent aggregation behavior regardless of LLM chunk size out_frame = AggregatedTextFrame(
characters = split_text_by_characters(in_frame.text) text=aggregation.text,
aggregated_by=aggregation.type,
for character in characters: )
aggregation = await self._text_aggregator.aggregate(character) out_frame.skip_tts = in_frame.skip_tts
if aggregation: await self.push_frame(out_frame)
out_frame = AggregatedTextFrame(
text=aggregation.text,
aggregated_by=aggregation.type,
)
out_frame.skip_tts = in_frame.skip_tts
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 text # Flush any remaining text

View File

@@ -51,7 +51,6 @@ from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_service import AIService from pipecat.services.ai_service import AIService
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 split_text_by_characters
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator 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.text.simple_text_aggregator import SimpleTextAggregator
@@ -544,19 +543,13 @@ class TTSService(AIService):
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
) )
else: else:
# Split text by characters to normalize input into individual characters async for aggregate in self._text_aggregator.aggregate(frame.text):
# This ensures consistent aggregation behavior regardless of input chunk size text = aggregate.text
characters = split_text_by_characters(frame.text) aggregated_by = aggregate.type
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
for character in characters: await self._push_tts_frames(
aggregate = await self._text_aggregator.aggregate(character) AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
if aggregate: )
text = aggregate.text
aggregated_by = aggregate.type
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
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

@@ -280,27 +280,3 @@ def concatenate_aggregated_text(text_parts: List[TextPartForConcatenation]) -> s
result = result.strip() result = result.strip()
return result return result
def split_text_by_characters(text: str) -> List[str]:
"""Split text into individual characters.
Returns each character as a separate string element, allowing character-by-character
processing while maintaining the ability to reconstruct the original text.
Args:
text: The text to split into characters.
Returns:
A list of individual characters.
Example::
>>> split_text_by_characters("Hello world!")
["H", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d", "!"]
>>> split_text_by_characters("Hi")
["H", "i"]
>>> split_text_by_characters("")
[]
"""
return list(text)

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,35 +80,32 @@ 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.
Returns: Yields:
An Aggregation object if ready for processing, or None if more Aggregation objects as they complete. Each Aggregation consists of
text is needed before the aggregated content is ready. If an Aggregation the aggregated text (stripped of leading/trailing whitespace) and
object is returned, it should consist of the updated aggregated text, a string indicating the type of aggregation (e.g., 'sentence', 'word',
stripped of leading/trailing whitespace, and a string indicating the 'token', 'my_custom_aggregation').
type of aggregation (e.g., 'sentence', 'word', 'token', 'my_custom_aggregation').
""" """
pass pass
# Make this a generator to satisfy type checker
yield # pragma: no cover
@abstractmethod @abstractmethod
async def flush(self) -> Optional[Aggregation]: async def flush(self) -> Optional[Aggregation]:

View File

@@ -13,7 +13,7 @@ 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
@@ -256,20 +256,6 @@ class PatternPairAggregator(SimpleTextAggregator):
matches = list(match_iter) # Convert to list for safe iteration matches = list(match_iter) # Convert to list for safe iteration
for match in matches: for match in matches:
# Only process patterns that end at or after last_processed_position
# This ensures we only call handlers once when a pattern completes
if match.end() <= last_processed_position:
# This pattern was already processed in a previous call
if action != MatchAction.REMOVE:
# For KEEP/AGGREGATE patterns, we still need to track them
content = match.group(1)
full_match = match.group(0)
pattern_match = PatternMatch(
content=content.strip(), type=type, full_match=full_match
)
all_matches.append(pattern_match)
continue
content = match.group(1) # Content between patterns content = match.group(1) # Content between patterns
full_match = match.group(0) # Full match including patterns full_match = match.group(0) # Full match including patterns
@@ -278,17 +264,23 @@ class PatternPairAggregator(SimpleTextAggregator):
content=content.strip(), type=type, full_match=full_match content=content.strip(), type=type, full_match=full_match
) )
# Call the appropriate handler if registered (only for newly complete patterns) # 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
@@ -324,72 +316,74 @@ class PatternPairAggregator(SimpleTextAggregator):
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 uses the parent's lookahead logic for sentence detection when and uses the parent's lookahead logic for sentence detection when no
no patterns are active. patterns are active.
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 newly complete patterns in the buffer # Process any newly complete patterns in the buffer
# Only patterns that complete after _last_processed_position will trigger handlers # Only patterns that complete after _last_processed_position will trigger handlers
patterns, processed_text = await self._process_complete_patterns( patterns, processed_text = await self._process_complete_patterns(
self._text, self._last_processed_position self._text, self._last_processed_position
) )
# Update the last processed position before modifying the text # Update the last processed position to prevent re-processing patterns
# For REMOVE patterns, the text will be shorter, so we track the original position # This tracks where in the buffer we've already called handlers, so we
self._last_processed_position = len(self._text) # only trigger handlers once when a pattern completes
self._last_processed_position = len(self._text)
self._text = processed_text self._text = processed_text
if len(patterns) > 0: if len(patterns) > 0:
if len(patterns) > 1: if len(patterns) > 1:
logger.warning( logger.warning(
f"Multiple patterns matched: {[p.type for p in patterns]}. Only the first pattern will be returned." 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
)
# Use parent's lookahead logic for sentence detection
aggregation = await super()._check_sentence_with_lookahead(text)
if aggregation:
# Convert to PatternMatch for consistency with return type
return PatternMatch(
content=aggregation.text, type=aggregation.type, full_match=aggregation.text
)
# No complete sentence found yet
return None
async def handle_interruption(self): async def handle_interruption(self):
"""Handle interruptions by clearing the buffer and pattern state. """Handle interruptions by clearing the buffer and pattern state.

View File

@@ -11,7 +11,7 @@ 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 SENTENCE_ENDING_PUNCTUATION, 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
@@ -42,27 +42,30 @@ 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. When sentence-ending punctuation is Processes the input text character-by-character. When sentence-ending
detected, it waits for non-whitespace lookahead before calling NLTK. punctuation is detected, it waits for non-whitespace lookahead before
This prevents false positives like "$29." being detected as a sentence calling NLTK. This prevents false positives like "$29." being detected
when it's actually "$29.95", and avoids unnecessary NLTK calls. as a sentence when it's actually "$29.95".
Args: Args:
text: New text to add to the aggregation buffer. text: Text to aggregate.
Returns: Yields:
A complete sentence if an end-of-sentence marker is confirmed, Complete sentences as Aggregation objects.
or None if more text is needed.
""" """
# Add new text to buffer # Process text character by character
self._text += text for char in text:
self._text += char
return await self._check_sentence_with_lookahead(text) # Check for sentence with lookahead
result = await self._check_sentence_with_lookahead(char)
if result:
yield result
async def _check_sentence_with_lookahead(self, text: str) -> Optional[Aggregation]: async def _check_sentence_with_lookahead(self, char: str) -> Optional[Aggregation]:
"""Check for sentence boundaries using lookahead logic. """Check for sentence boundaries using lookahead logic.
This method implements the core sentence detection logic with lookahead. This method implements the core sentence detection logic with lookahead.
@@ -77,7 +80,7 @@ class SimpleTextAggregator(BaseTextAggregator):
while adding their own logic (e.g., tag handling, pattern matching). while adding their own logic (e.g., tag handling, pattern matching).
Args: Args:
text: The most recently added text (used for lookahead check). char: The most recently added character (used for lookahead check).
Returns: Returns:
Aggregation if sentence found, None otherwise. Aggregation if sentence found, None otherwise.
@@ -85,7 +88,7 @@ class SimpleTextAggregator(BaseTextAggregator):
# If we need lookahead, check if we now have non-whitespace # If we need lookahead, check if we now have non-whitespace
if self._needs_lookahead: if self._needs_lookahead:
# Check if the new character is non-whitespace # Check if the new character is non-whitespace
if text.strip(): if char.strip():
# We have meaningful lookahead, call NLTK # We have meaningful lookahead, call NLTK
self._needs_lookahead = False self._needs_lookahead = False
eos_marker = match_endofsentence(self._text) eos_marker = match_endofsentence(self._text)
@@ -101,7 +104,7 @@ class SimpleTextAggregator(BaseTextAggregator):
return None return None
# Check if we just added sentence-ending punctuation # Check if we just added sentence-ending punctuation
if self._text[-1] in SENTENCE_ENDING_PUNCTUATION: if self._text and self._text[-1] in SENTENCE_ENDING_PUNCTUATION:
# Mark that we need lookahead (don't call NLTK yet) # Mark that we need lookahead (don't call NLTK yet)
self._needs_lookahead = True self._needs_lookahead = True

View File

@@ -11,7 +11,7 @@ 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, parse_start_end_tags from pipecat.utils.string import StartEndTags, parse_start_end_tags
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType
@@ -43,35 +43,37 @@ class SkipTagsAggregator(SimpleTextAggregator):
self._current_tag: Optional[StartEndTags] = None self._current_tag: Optional[StartEndTags] = None
self._current_tag_index: int = 0 self._current_tag_index: int = 0
async def aggregate(self, text: str) -> Optional[Aggregation]: async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate text while respecting tag boundaries. """Aggregate text while respecting tag boundaries.
This method adds the new text to the buffer, updates tag state, Processes the input text character-by-character, updates tag state, and
and uses the parent's lookahead logic for sentence detection when uses the parent's lookahead logic for sentence detection when not
not inside tags. inside tags.
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
# Update tag state # Update tag state
(self._current_tag, self._current_tag_index) = parse_start_end_tags( (self._current_tag, self._current_tag_index) = parse_start_end_tags(
self._text, self._tags, self._current_tag, self._current_tag_index self._text, self._tags, self._current_tag, self._current_tag_index
) )
# If inside tags, don't check for sentences # If inside tags, don't check for sentences
if self._current_tag: if self._current_tag:
return None continue
# Otherwise, use parent's lookahead logic for sentence detection # Otherwise, use parent's lookahead logic for sentence detection
return await super()._check_sentence_with_lookahead(text) 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 and tag state. """Handle interruptions by clearing the buffer and tag state.

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):
# Feed text character by character text = "Hello <test>pattern content</test>!"
full_text = "Hello <test>pattern content</test>!" results = [result async for result in self.aggregator.aggregate(text)]
results = []
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
results.append(result)
# 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()
@@ -59,10 +53,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(results), 0) self.assertEqual(len(results), 0)
# Next sentence should provide the lookahead and trigger the previous sentence # Next sentence should provide the lookahead and trigger the previous sentence
for char in " This is another sentence.": async for result in self.aggregator.aggregate(" This is another sentence."):
result = await self.aggregator.aggregate(char) results.append(result)
if result:
results.append(result)
# First result should be "Hello !" triggered by the space lookahead # First result should be "Hello !" triggered by the space lookahead
self.assertEqual(len(results), 1) self.assertEqual(len(results), 1)
@@ -77,14 +69,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
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):
# Feed text character by character, collecting all results text = "Here is code <code>pattern content</code> This is another sentence."
results = []
full_text = "Here is code <code>pattern content</code> This is another sentence."
for char in full_text: results = [result async for result in self.aggregator.aggregate(text)]
result = await self.aggregator.aggregate(char)
if result:
results.append(result)
# First result should be "Here is code" when pattern starts # First result should be "Here is code" when pattern starts
self.assertEqual(results[0].text, "Here is code") self.assertEqual(results[0].text, "Here is code")
@@ -111,15 +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):
# Feed text character by character with incomplete pattern text = "Hello <test>pattern content"
result = None results = [result async for result in self.aggregator.aggregate(text)]
for char in "Hello <test>pattern content":
result = await self.aggregator.aggregate(char)
if result:
break
# 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()
@@ -154,13 +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)
# Feed text character by character
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 = None results = [result async for result in self.aggregator.aggregate(text)]
for char in text:
result = await self.aggregator.aggregate(char)
if result:
break
# 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()
@@ -174,7 +151,7 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(emphasis_match.text, "very") self.assertEqual(emphasis_match.text, "very")
# With lookahead, we need to flush to get the final sentence # With lookahead, we need to flush to get the final sentence
self.assertIsNone(result) # Waiting for lookahead after "!" self.assertEqual(len(results), 0) # Waiting for lookahead after "!"
result = await self.aggregator.flush() result = await self.aggregator.flush()
# Voice pattern should be removed, emphasis pattern should remain # Voice pattern should be removed, emphasis pattern should remain
@@ -184,14 +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):
# Feed text character by character with incomplete pattern text = "Hello <test>pattern"
result = None results = [result async for result in self.aggregator.aggregate(text)]
for char in "Hello <test>pattern": self.assertEqual(len(results), 0)
result = await self.aggregator.aggregate(char)
if result:
break
self.assertIsNone(result)
# Simulate interruption # Simulate interruption
await self.aggregator.handle_interruption() await self.aggregator.handle_interruption()
@@ -203,14 +175,8 @@ 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):
# Feed text character by character - pattern spans multiple sentences text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence."
full_text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence." results = [result async for result in self.aggregator.aggregate(text)]
result = None
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
break
# 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()
@@ -218,7 +184,7 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
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 # With lookahead, we need to flush to get the final sentence
self.assertIsNone(result) # Waiting for lookahead after "." self.assertEqual(len(results), 0) # Waiting for lookahead after "."
result = await self.aggregator.flush() 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

View File

@@ -14,19 +14,22 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator = SimpleTextAggregator() self.aggregator = SimpleTextAggregator()
async def test_reset_aggregations(self): async def test_reset_aggregations(self):
# Feed character-by-character text = "Hello "
for char in "Hello ": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# 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):
# Feed character-by-character: "Hello Pipecat!" text = "Hello Pipecat!"
for char in "Hello Pipecat": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# After "!", lookahead waits for confirmation # No complete sentences yet (waiting for lookahead after "!")
assert await self.aggregator.aggregate("!") == None assert len(results) == 0
# Flush to get the pending sentence # Flush to get the pending sentence
aggregate = await self.aggregator.flush() aggregate = await self.aggregator.flush()
assert aggregate.text == "Hello Pipecat!" assert aggregate.text == "Hello Pipecat!"
@@ -34,81 +37,58 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
assert self.aggregator.text.text == "" assert self.aggregator.text.text == ""
async def test_multiple_sentences(self): async def test_multiple_sentences(self):
# Feed character-by-character: "Hello Pipecat! How are you?" text = "Hello Pipecat! How are you?"
for char in "Hello Pipecat": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# Hit "!" - wait for lookahead # First sentence should be complete (lookahead from "H" confirmed it)
assert await self.aggregator.aggregate("!") == None assert len(results) == 1
# Space is whitespace - keep waiting assert results[0].text == "Hello Pipecat!"
assert await self.aggregator.aggregate(" ") == None
# "H" confirms sentence end
result = await self.aggregator.aggregate("H")
assert result.text == "Hello Pipecat!"
# Continue with second sentence
for char in "ow are you":
assert await self.aggregator.aggregate(char) == None
# Hit "?" - wait for lookahead
assert await self.aggregator.aggregate("?") == None
# Flush to get the pending sentence # Flush to get the pending sentence
result = await self.aggregator.flush() result = await self.aggregator.flush()
assert result.text == "How are you?" assert result.text == "How are you?"
async def test_lookahead_decimal_number(self): async def test_lookahead_decimal_number(self):
"""Test that $29.95 is not split at $29.""" """Test that $29.95 is not split at $29."""
# Feed character by character: "Ask me for only $29.95/month." text = "Ask me for only $29.95/month."
for char in "Ask me for only $29": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# When we hit ".", it looks like end of sentence, but should wait for lookahead # No complete sentences yet (waiting for lookahead after final ".")
assert await self.aggregator.aggregate(".") == None assert len(results) == 0
# Next character "9" confirms it's not end of sentence (NLTK changes boundary)
for char in "95/month":
assert await self.aggregator.aggregate(char) == None
# Now we hit the real end of sentence - wait for lookahead
assert await self.aggregator.aggregate(".") == None
# Can use flush() to get the pending sentence at end of stream # Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush() result = await self.aggregator.flush()
assert result.text == "Ask me for only $29.95/month." assert result.text == "Ask me for only $29.95/month."
async def test_lookahead_abbreviation(self): async def test_lookahead_abbreviation(self):
"""Test that Mr. Smith is not split at Mr.""" """Test that Mr. Smith is not split at Mr."""
# Feed character by character: "Hello Mr. Smith." text = "Hello Mr. Smith."
for char in "Hello Mr": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# When we hit ".", it looks like end of sentence, but should wait for lookahead # No complete sentences yet (waiting for lookahead after final ".")
assert await self.aggregator.aggregate(".") == None assert len(results) == 0
# Space alone is not enough
assert await self.aggregator.aggregate(" ") == None
# "S" confirms it's not end of sentence (NLTK changes boundary detection)
for char in "Smith":
assert await self.aggregator.aggregate(char) == None
# Now we hit the real end of sentence - wait for lookahead
assert await self.aggregator.aggregate(".") == None
# Can use flush() to get the pending sentence at end of stream # Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush() result = await self.aggregator.flush()
assert result.text == "Hello Mr. Smith." assert result.text == "Hello Mr. Smith."
async def test_lookahead_actual_sentence_end(self): async def test_lookahead_actual_sentence_end(self):
"""Test that a real sentence end is detected after lookahead.""" """Test that a real sentence end is detected after lookahead."""
# Feed character by character: "Hello world. Next sentence" text = "Hello world. Next sentence"
for char in "Hello world": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
# Hit period - should wait for lookahead # First sentence should be complete (lookahead from "N" confirmed it)
assert await self.aggregator.aggregate(".") == None assert len(results) == 1
# Space alone is not enough - need non-whitespace for meaningful lookahead assert results[0].text == "Hello world."
assert await self.aggregator.aggregate(" ") == None
# Capital letter confirms sentence end (NLTK detects boundary at same position)
result = await self.aggregator.aggregate("N")
assert result.text == "Hello world."
# Continue with next sentence
assert await self.aggregator.aggregate("e") == None
async def test_flush_pending_sentence(self): async def test_flush_pending_sentence(self):
"""Test that flush() returns pending sentence waiting for lookahead.""" """Test that flush() returns pending sentence waiting for lookahead."""
# Feed up to a period text = "Hello world."
for char in "Hello world": results = [agg async for agg in self.aggregator.aggregate(text)]
assert await self.aggregator.aggregate(char) == None
assert await self.aggregator.aggregate(".") == None # No complete sentences yet (waiting for lookahead)
# At this point, "Hello world." is pending lookahead assert len(results) == 0
# Call flush to get it # Call flush to get it
result = await self.aggregator.flush() result = await self.aggregator.flush()
assert result is not None assert result is not None
@@ -118,7 +98,12 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
async def test_flush_with_no_pending(self): async def test_flush_with_no_pending(self):
"""Test that flush() returns any remaining text in buffer.""" """Test that flush() returns any remaining text in buffer."""
assert await self.aggregator.aggregate("Hello") == None text = "Hello"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences
assert len(results) == 0
result = await self.aggregator.flush() result = await self.aggregator.flush()
# flush() now returns any remaining text, not just pending lookahead # flush() now returns any remaining text, not just pending lookahead
assert result is not None assert result is not None
@@ -128,13 +113,13 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
async def test_flush_after_lookahead_confirmed(self): async def test_flush_after_lookahead_confirmed(self):
"""Test flush after lookahead has already confirmed sentence.""" """Test flush after lookahead has already confirmed sentence."""
for char in "Hello.": text = "Hello. W"
await self.aggregator.aggregate(char) results = [agg async for agg in self.aggregator.aggregate(text)]
# Space alone is not enough - still waiting
assert await self.aggregator.aggregate(" ") == None # First sentence should be complete (lookahead from "W" confirmed it)
# Non-whitespace lookahead confirms it's a sentence assert len(results) == 1
result = await self.aggregator.aggregate("W") assert results[0].text == "Hello."
assert result.text == "Hello."
# flush() returns any remaining text (the "W" in this case) # flush() returns any remaining text (the "W" in this case)
result = await self.aggregator.flush() result = await self.aggregator.flush()
assert result.text == "W" assert result.text == "W"

View File

@@ -17,15 +17,11 @@ 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.
# Feed text character by character text = "Hello Pipecat!"
result = None results = [agg async for agg in self.aggregator.aggregate(text)]
for char in "Hello Pipecat!":
result = await self.aggregator.aggregate(char)
if result:
break
# Should still be waiting for lookahead after "!" # Should still be waiting for lookahead after "!"
self.assertIsNone(result) self.assertEqual(len(results), 0)
# Flush to get the pending sentence # Flush to get the pending sentence
result = await self.aggregator.flush() result = await self.aggregator.flush()
@@ -37,15 +33,11 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset() await self.aggregator.reset()
# Tags involved, avoid aggregation during tags. # Tags involved, avoid aggregation during tags.
# Feed text character by character text = "My email is <spell>foo@pipecat.ai</spell>."
result = None results = [agg async for agg in self.aggregator.aggregate(text)]
for char in "My email is <spell>foo@pipecat.ai</spell>.":
result = await self.aggregator.aggregate(char)
if result:
break
# Should still be waiting for lookahead after "." # Should still be waiting for lookahead after "."
self.assertIsNone(result) self.assertEqual(len(results), 0)
# Flush to get the pending sentence # Flush to get the pending sentence
result = await self.aggregator.flush() result = await self.aggregator.flush()
@@ -56,22 +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, feed character by character # Tags involved
full_text = "My email is <spell>foo.bar@pipecat.ai</spell>." text = "My email is <spell>foo.bar@pipecat.ai</spell>."
result = None results = [agg async for agg in self.aggregator.aggregate(text)]
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
break
# Should still be waiting for lookahead after "." # Should still be waiting for lookahead after "."
self.assertIsNone(result) self.assertEqual(len(results), 0)
self.assertEqual(self.aggregator.text.text, full_text) self.assertEqual(self.aggregator.text.text, text)
self.assertEqual(self.aggregator.text.type, "sentence") self.assertEqual(self.aggregator.text.type, "sentence")
# Flush to get the pending sentence # Flush to get the pending sentence
result = await self.aggregator.flush() result = await self.aggregator.flush()
self.assertEqual(result.text, full_text) 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")

View File

@@ -6,7 +6,7 @@
import unittest import unittest
from pipecat.utils.string import match_endofsentence, parse_start_end_tags, split_text_by_characters from pipecat.utils.string import match_endofsentence, parse_start_end_tags
class TestUtilsString(unittest.IsolatedAsyncioTestCase): class TestUtilsString(unittest.IsolatedAsyncioTestCase):
@@ -232,35 +232,3 @@ class TestStartEndTags(unittest.IsolatedAsyncioTestCase):
("<a>", "</a>"), ("<a>", "</a>"),
41, 41,
) )
async def test_split_text_by_characters(self):
"""Test splitting text into individual characters."""
# Basic sentence
assert split_text_by_characters("Hello world!") == [
"H",
"e",
"l",
"l",
"o",
" ",
"w",
"o",
"r",
"l",
"d",
"!",
]
# Single word
assert split_text_by_characters("Hi") == ["H", "i"]
# Empty string
assert split_text_by_characters("") == []
# With spaces
assert split_text_by_characters("A B") == ["A", " ", "B"]
# Concatenation test - characters should concatenate back to original
characters = split_text_by_characters("Hello world!")
concatenated = "".join(characters)
assert concatenated == "Hello world!"

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 = [