Merge pull request #3637 from pipecat-ai/mb/improve-user-stop-turn
Improve user turn stop timing by triggering timeout from VAD stop, push STT metadata to user aggregator
This commit is contained in:
1
changelog/3637.added.3.md
Normal file
1
changelog/3637.added.3.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Added `RequestMetadataFrame` and metadata handling for `ServiceSwitcher` to ensure STT services correctly emit `STTMetadataFrame` when switching between services. Only the active service's metadata is propagated downstream, switching services triggers the newly active service to re-emit its metadata, and proper frame ordering is maintained at startup.
|
||||||
6
changelog/3637.added.md
Normal file
6
changelog/3637.added.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
- Added `STTMetadataFrame` to broadcast STT service latency information at pipeline start.
|
||||||
|
- STT services broadcast P99 time-to-final-segment (`ttfs_p99_latency`) to downstream processors
|
||||||
|
- Turn stop strategies automatically configure their STT timeout from this metadata
|
||||||
|
- Developers can override `ttfs_p99_latency` via constructor argument for custom deployments
|
||||||
|
- Added measured P99 values for STT providers.
|
||||||
|
- See [stt-benchmark](https://github.com/pipecat-ai/stt-benchmark) to measure latency for your configuration
|
||||||
5
changelog/3637.changed.2.md
Normal file
5
changelog/3637.changed.2.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
- Improved user turn stop timing in `TranscriptionUserTurnStopStrategy` and `TurnAnalyzerUserTurnStopStrategy`.
|
||||||
|
- Timeout now starts on `VADUserStoppedSpeakingFrame` for tighter, more predictable timing
|
||||||
|
- Added support for finalized transcripts (`TranscriptionFrame.finalized=True`) to trigger earlier
|
||||||
|
- Added fallback timeout for edge cases where transcripts arrive without VAD events
|
||||||
|
- Removed `InterimTranscriptionFrame` handling (no longer affects timing)
|
||||||
1
changelog/3637.changed.3.md
Normal file
1
changelog/3637.changed.3.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Updated the `VADUserStartedSpeakingFrame` to include `start_secs` and `timestamp` and `VADUserStoppedSpeakingFrame` to include `stop_secs` and `timestamp`, removing the need to separately handle the `SpeechControlParamsFrame` for VADParams values.
|
||||||
1
changelog/3637.changed.4.md
Normal file
1
changelog/3637.changed.4.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- ⚠️ Renamed `TranscriptionUserTurnStopStrategy` to `SpeechTimeoutUserTurnStopStrategy`. The old name is deprecated and will be removed in a future release.
|
||||||
1
changelog/3637.changed.5.md
Normal file
1
changelog/3637.changed.5.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Improved the accuracy of the `UserBotLatencyObserver` and `UserBotLatencyLogObserver` by measuring from the time when the user actually starts speaking.
|
||||||
1
changelog/3637.changed.md
Normal file
1
changelog/3637.changed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- ⚠️ Renamed `timeout` parameter to `user_speech_timeout` in `TranscriptionUserTurnStopStrategy`.
|
||||||
1
changelog/3637.removed.md
Normal file
1
changelog/3637.removed.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- ⚠️ Removed `timeout` parameter from `TurnAnalyzerUserTurnStopStrategy`. The timeout is now managed internally based on STT latency.
|
||||||
@@ -12,6 +12,7 @@ and LLM processing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -1270,16 +1271,32 @@ class EmulateUserStoppedSpeakingFrame(SystemFrame):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VADUserStartedSpeakingFrame(SystemFrame):
|
class VADUserStartedSpeakingFrame(SystemFrame):
|
||||||
"""Frame emitted when VAD definitively detects user started speaking."""
|
"""Frame emitted when VAD definitively detects user started speaking.
|
||||||
|
|
||||||
pass
|
Parameters:
|
||||||
|
start_secs: The VAD start_secs duration that was used to confirm the user
|
||||||
|
started speaking. This represents the speech duration that had to
|
||||||
|
elapse before the VAD determined speech began.
|
||||||
|
timestamp: Wall-clock time when the VAD made its determination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_secs: float = 0.0
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VADUserStoppedSpeakingFrame(SystemFrame):
|
class VADUserStoppedSpeakingFrame(SystemFrame):
|
||||||
"""Frame emitted when VAD definitively detects user stopped speaking."""
|
"""Frame emitted when VAD definitively detects user stopped speaking.
|
||||||
|
|
||||||
pass
|
Parameters:
|
||||||
|
stop_secs: The VAD stop_secs duration that was used to confirm the user
|
||||||
|
stopped speaking. This represents the silence duration that had to
|
||||||
|
elapse before the VAD determined speech ended.
|
||||||
|
timestamp: Wall-clock time when the VAD made its determination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stop_secs: float = 0.0
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -1651,6 +1668,49 @@ class SpeechControlParamsFrame(SystemFrame):
|
|||||||
turn_params: Optional[BaseTurnParams] = None
|
turn_params: Optional[BaseTurnParams] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceMetadataFrame(SystemFrame):
|
||||||
|
"""Base metadata frame for services.
|
||||||
|
|
||||||
|
Broadcast by services at pipeline start to share service-specific
|
||||||
|
configuration and performance characteristics with downstream processors.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
service_name: The name of the service broadcasting this metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class STTMetadataFrame(ServiceMetadataFrame):
|
||||||
|
"""Metadata from STT service.
|
||||||
|
|
||||||
|
Broadcast by STT services to inform downstream processors (like turn
|
||||||
|
strategies) about STT latency characteristics.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
ttfs_p99_latency: Time to final segment P99 latency in seconds.
|
||||||
|
This is the expected time from when speech ends to when the
|
||||||
|
final transcript is received, at the 99th percentile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ttfs_p99_latency: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequestMetadataFrame(ControlFrame):
|
||||||
|
"""Request services to re-emit their metadata frames.
|
||||||
|
|
||||||
|
Used by ServiceSwitcher when switching active services to ensure
|
||||||
|
downstream processors receive updated metadata from the newly active service.
|
||||||
|
Services that receive this frame should re-push their metadata frame
|
||||||
|
(e.g., STTMetadataFrame for STT services).
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Task frames
|
# Task frames
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class UserBotLatencyLogObserver(BaseObserver):
|
|||||||
if isinstance(data.frame, VADUserStartedSpeakingFrame):
|
if isinstance(data.frame, VADUserStartedSpeakingFrame):
|
||||||
self._user_stopped_time = 0
|
self._user_stopped_time = 0
|
||||||
elif isinstance(data.frame, VADUserStoppedSpeakingFrame):
|
elif isinstance(data.frame, VADUserStoppedSpeakingFrame):
|
||||||
self._user_stopped_time = time.time()
|
self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs
|
||||||
elif isinstance(data.frame, (EndFrame, CancelFrame)):
|
elif isinstance(data.frame, (EndFrame, CancelFrame)):
|
||||||
self._log_summary()
|
self._log_summary()
|
||||||
elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time:
|
elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time:
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ class UserBotLatencyObserver(BaseObserver):
|
|||||||
# Reset when user starts speaking
|
# Reset when user starts speaking
|
||||||
self._user_stopped_time = None
|
self._user_stopped_time = None
|
||||||
elif isinstance(data.frame, VADUserStoppedSpeakingFrame):
|
elif isinstance(data.frame, VADUserStoppedSpeakingFrame):
|
||||||
# Record timestamp when user stops speaking
|
# Record the actual time the user stopped speaking, which is
|
||||||
self._user_stopped_time = time.time()
|
# the VAD determination time minus the stop_secs silence duration
|
||||||
|
# that had to elapse before the VAD confirmed speech ended.
|
||||||
|
self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs
|
||||||
elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time:
|
elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time:
|
||||||
# Calculate and emit latency
|
# Calculate and emit latency
|
||||||
latency = time.time() - self._user_stopped_time
|
latency = time.time() - self._user_stopped_time
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class LLMSwitcher(ServiceSwitcher[StrategyType]):
|
|||||||
return self.services
|
return self.services
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active_llm(self) -> Optional[LLMService]:
|
def active_llm(self) -> LLMService:
|
||||||
"""Get the currently active LLM.
|
"""Get the currently active LLM.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -6,26 +6,38 @@
|
|||||||
|
|
||||||
"""Service switcher for switching between different services at runtime, with different switching strategies."""
|
"""Service switcher for switching between different services at runtime, with different switching strategies."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from abc import abstractmethod
|
||||||
from typing import Any, Generic, List, Optional, Type, TypeVar
|
from typing import Any, Generic, List, Optional, Type, TypeVar
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
ControlFrame,
|
|
||||||
Frame,
|
Frame,
|
||||||
ManuallySwitchServiceFrame,
|
ManuallySwitchServiceFrame,
|
||||||
|
RequestMetadataFrame,
|
||||||
|
ServiceMetadataFrame,
|
||||||
ServiceSwitcherFrame,
|
ServiceSwitcherFrame,
|
||||||
)
|
)
|
||||||
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
|
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
|
||||||
from pipecat.processors.filters.function_filter import FunctionFilter
|
from pipecat.processors.filters.function_filter import FunctionFilter
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.utils.base_object import BaseObject
|
||||||
|
|
||||||
|
|
||||||
class ServiceSwitcherStrategy:
|
class ServiceSwitcherStrategy(BaseObject):
|
||||||
"""Base class for service switching strategies.
|
"""Base class for service switching strategies.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
Strategy classes are instantiated internally by ServiceSwitcher.
|
Strategy classes are instantiated internally by ServiceSwitcher.
|
||||||
Developers should pass the strategy class (not an instance) to ServiceSwitcher.
|
Developers should pass the strategy class (not an instance) to ServiceSwitcher.
|
||||||
|
|
||||||
|
Event handlers available:
|
||||||
|
|
||||||
|
- on_service_switched: Called when the active service changes.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
@strategy.event_handler("on_service_switched")
|
||||||
|
async def on_service_switched(strategy, service):
|
||||||
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, services: List[FrameProcessor]):
|
def __init__(self, services: List[FrameProcessor]):
|
||||||
@@ -37,20 +49,42 @@ class ServiceSwitcherStrategy:
|
|||||||
Args:
|
Args:
|
||||||
services: List of frame processors to switch between.
|
services: List of frame processors to switch between.
|
||||||
"""
|
"""
|
||||||
self.services = services
|
super().__init__()
|
||||||
self.active_service: Optional[FrameProcessor] = None
|
|
||||||
|
|
||||||
def handle_frame(self, frame: ServiceSwitcherFrame, direction: FrameDirection):
|
if len(services) == 0:
|
||||||
|
raise Exception(f"ServiceSwitcherStrategy needs at least one service")
|
||||||
|
|
||||||
|
self._services = services
|
||||||
|
self._active_service = services[0]
|
||||||
|
|
||||||
|
self._register_event_handler("on_service_switched")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def services(self) -> List[FrameProcessor]:
|
||||||
|
"""Return the list of available services."""
|
||||||
|
return self._services
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_service(self) -> FrameProcessor:
|
||||||
|
"""Return the currently active service."""
|
||||||
|
return self._active_service
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle_frame(
|
||||||
|
self, frame: ServiceSwitcherFrame, direction: FrameDirection
|
||||||
|
) -> Optional[FrameProcessor]:
|
||||||
"""Handle a frame that controls service switching.
|
"""Handle a frame that controls service switching.
|
||||||
|
|
||||||
This method can be overridden by subclasses to implement specific logic
|
Subclasses implement this to decide whether a switch should occur.
|
||||||
for handling frames that control service switching.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
frame: The frame to handle.
|
frame: The frame to handle.
|
||||||
direction: The direction of the frame (upstream or downstream).
|
direction: The direction of the frame (upstream or downstream).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly active service if a switch occurred, or None otherwise.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Subclasses must implement this method.")
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy):
|
class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy):
|
||||||
@@ -67,31 +101,24 @@ class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy):
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, services: List[FrameProcessor]):
|
async def handle_frame(
|
||||||
"""Initialize the manual service switcher strategy with a list of services.
|
self, frame: ServiceSwitcherFrame, direction: FrameDirection
|
||||||
|
) -> Optional[FrameProcessor]:
|
||||||
Note:
|
|
||||||
This is called internally by ServiceSwitcher. Do not instantiate directly.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
services: List of frame processors to switch between.
|
|
||||||
"""
|
|
||||||
super().__init__(services)
|
|
||||||
self.active_service = services[0] if services else None
|
|
||||||
|
|
||||||
def handle_frame(self, frame: ServiceSwitcherFrame, direction: FrameDirection):
|
|
||||||
"""Handle a frame that controls service switching.
|
"""Handle a frame that controls service switching.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
frame: The frame to handle.
|
frame: The frame to handle.
|
||||||
direction: The direction of the frame (upstream or downstream).
|
direction: The direction of the frame (upstream or downstream).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly active service if a switch occurred, or None otherwise.
|
||||||
"""
|
"""
|
||||||
if isinstance(frame, ManuallySwitchServiceFrame):
|
if isinstance(frame, ManuallySwitchServiceFrame):
|
||||||
self._set_active_if_available(frame.service)
|
return await self._set_active_if_available(frame.service)
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported frame type: {type(frame)}")
|
|
||||||
|
|
||||||
def _set_active_if_available(self, service: FrameProcessor):
|
return None
|
||||||
|
|
||||||
|
async def _set_active_if_available(self, service: FrameProcessor) -> Optional[FrameProcessor]:
|
||||||
"""Set the active service to the given one, if it is in the list of available services.
|
"""Set the active service to the given one, if it is in the list of available services.
|
||||||
|
|
||||||
If it's not in the list, the request is ignored, as it may have been
|
If it's not in the list, the request is ignored, as it may have been
|
||||||
@@ -99,16 +126,35 @@ class ServiceSwitcherStrategyManual(ServiceSwitcherStrategy):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
service: The service to set as active.
|
service: The service to set as active.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly active service, or None if the service was not found.
|
||||||
"""
|
"""
|
||||||
if service in self.services:
|
if service in self.services:
|
||||||
self.active_service = service
|
self._active_service = service
|
||||||
|
await self._call_event_handler("on_service_switched", service)
|
||||||
|
return service
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
StrategyType = TypeVar("StrategyType", bound=ServiceSwitcherStrategy)
|
StrategyType = TypeVar("StrategyType", bound=ServiceSwitcherStrategy)
|
||||||
|
|
||||||
|
|
||||||
class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]):
|
class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]):
|
||||||
"""A pipeline that switches between different services at runtime."""
|
"""Parallel pipeline that routes frames to one active service at a time.
|
||||||
|
|
||||||
|
Wraps each service in a pair of filters that gate frame flow based on
|
||||||
|
which service is currently active. Switching is controlled by
|
||||||
|
`ServiceSwitcherFrame` frames and delegated to a pluggable
|
||||||
|
`ServiceSwitcherStrategy`.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
switcher = ServiceSwitcher(
|
||||||
|
services=[stt_1, stt_2],
|
||||||
|
strategy_type=ServiceSwitcherStrategyManual,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, services: List[FrameProcessor], strategy_type: Type[StrategyType]):
|
def __init__(self, services: List[FrameProcessor], strategy_type: Type[StrategyType]):
|
||||||
"""Initialize the service switcher with a list of services and a switching strategy.
|
"""Initialize the service switcher with a list of services and a switching strategy.
|
||||||
@@ -117,53 +163,20 @@ class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]):
|
|||||||
services: List of frame processors to switch between.
|
services: List of frame processors to switch between.
|
||||||
strategy_type: The strategy class to use for switching between services.
|
strategy_type: The strategy class to use for switching between services.
|
||||||
"""
|
"""
|
||||||
strategy = strategy_type(services)
|
_strategy = strategy_type(services)
|
||||||
super().__init__(*self._make_pipeline_definitions(services, strategy))
|
super().__init__(*self._make_pipeline_definitions(services, _strategy))
|
||||||
self.services = services
|
self._services = services
|
||||||
self.strategy = strategy
|
self._strategy = _strategy
|
||||||
|
|
||||||
class ServiceSwitcherFilter(FunctionFilter):
|
@property
|
||||||
"""An internal filter that allows frames to pass through to the wrapped service only if it's the active service."""
|
def strategy(self) -> StrategyType:
|
||||||
|
"""Return the active switching strategy."""
|
||||||
|
return self._strategy
|
||||||
|
|
||||||
def __init__(
|
@property
|
||||||
self,
|
def services(self) -> List[FrameProcessor]:
|
||||||
wrapped_service: FrameProcessor,
|
"""Return the list of available services."""
|
||||||
active_service: FrameProcessor,
|
return self._services
|
||||||
direction: FrameDirection,
|
|
||||||
):
|
|
||||||
"""Initialize the service switcher filter with a strategy and direction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wrapped_service: The service that this filter wraps.
|
|
||||||
active_service: The currently active service.
|
|
||||||
direction: The direction of frame flow to filter.
|
|
||||||
"""
|
|
||||||
self._wrapped_service = wrapped_service
|
|
||||||
self._active_service = active_service
|
|
||||||
|
|
||||||
async def filter(_: Frame) -> bool:
|
|
||||||
return self._wrapped_service == self._active_service
|
|
||||||
|
|
||||||
super().__init__(filter, direction, filter_system_frames=True)
|
|
||||||
|
|
||||||
async def process_frame(self, frame, direction):
|
|
||||||
"""Process a frame through the filter, handling special internal filter-updating frames."""
|
|
||||||
if isinstance(frame, ServiceSwitcher.ServiceSwitcherFilterFrame):
|
|
||||||
self._active_service = frame.active_service
|
|
||||||
# Two ServiceSwitcherFilters "sandwich" a service. Push the
|
|
||||||
# frame only to update the other side of the sandwich, but
|
|
||||||
# otherwise don't let it leave the sandwich.
|
|
||||||
if direction == self._direction:
|
|
||||||
await self.push_frame(frame, direction)
|
|
||||||
return
|
|
||||||
|
|
||||||
await super().process_frame(frame, direction)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ServiceSwitcherFilterFrame(ControlFrame):
|
|
||||||
"""An internal frame used by ServiceSwitcher to filter frames based on active service."""
|
|
||||||
|
|
||||||
active_service: FrameProcessor
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_pipeline_definitions(
|
def _make_pipeline_definitions(
|
||||||
@@ -178,20 +191,53 @@ class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]):
|
|||||||
def _make_pipeline_definition(
|
def _make_pipeline_definition(
|
||||||
service: FrameProcessor, strategy: ServiceSwitcherStrategy
|
service: FrameProcessor, strategy: ServiceSwitcherStrategy
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
async def filter(_: Frame) -> bool:
|
||||||
|
return service == strategy.active_service
|
||||||
|
|
||||||
|
# Layout: Filter → Service → Filter
|
||||||
|
#
|
||||||
|
# filter_system_frames: we want to run filter functions also on system
|
||||||
|
# frames.
|
||||||
|
#
|
||||||
|
# enable_direct_mode: filter functions are quick so we don't need
|
||||||
|
# additional tasks.
|
||||||
return [
|
return [
|
||||||
ServiceSwitcher.ServiceSwitcherFilter(
|
FunctionFilter(
|
||||||
wrapped_service=service,
|
filter=filter,
|
||||||
active_service=strategy.active_service,
|
|
||||||
direction=FrameDirection.DOWNSTREAM,
|
direction=FrameDirection.DOWNSTREAM,
|
||||||
|
filter_system_frames=True,
|
||||||
|
enable_direct_mode=True,
|
||||||
),
|
),
|
||||||
service,
|
service,
|
||||||
ServiceSwitcher.ServiceSwitcherFilter(
|
FunctionFilter(
|
||||||
wrapped_service=service,
|
filter=filter,
|
||||||
active_service=strategy.active_service,
|
|
||||||
direction=FrameDirection.UPSTREAM,
|
direction=FrameDirection.UPSTREAM,
|
||||||
|
filter_system_frames=True,
|
||||||
|
enable_direct_mode=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
|
"""Push a frame out of the service switcher.
|
||||||
|
|
||||||
|
Suppresses `RequestMetadataFrame` (internal to the switcher) and
|
||||||
|
`ServiceMetadataFrame` from inactive services so only the active
|
||||||
|
service's metadata reaches downstream processors. One case this happens
|
||||||
|
is with `StartFrame` since all the filters let it pass, and `StartFrame`
|
||||||
|
causes the service to generate `ServiceMetadataFrame`.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Don't let RequestMetadataFrame out.
|
||||||
|
if isinstance(frame, RequestMetadataFrame):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only let metadata from the active service escape.
|
||||||
|
if isinstance(frame, ServiceMetadataFrame):
|
||||||
|
if frame.service_name != self.strategy.active_service.name:
|
||||||
|
return
|
||||||
|
|
||||||
|
await super().push_frame(frame, direction)
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
"""Process a frame, handling frames which affect service switching.
|
"""Process a frame, handling frames which affect service switching.
|
||||||
|
|
||||||
@@ -199,11 +245,16 @@ class ServiceSwitcher(ParallelPipeline, Generic[StrategyType]):
|
|||||||
frame: The frame to process.
|
frame: The frame to process.
|
||||||
direction: The direction of the frame (upstream or downstream).
|
direction: The direction of the frame (upstream or downstream).
|
||||||
"""
|
"""
|
||||||
await super().process_frame(frame, direction)
|
|
||||||
|
|
||||||
if isinstance(frame, ServiceSwitcherFrame):
|
if isinstance(frame, ServiceSwitcherFrame):
|
||||||
self.strategy.handle_frame(frame, direction)
|
service = await self.strategy.handle_frame(frame, direction)
|
||||||
service_switcher_filter_frame = ServiceSwitcher.ServiceSwitcherFilterFrame(
|
|
||||||
active_service=self.strategy.active_service
|
# If we don't switch to a new service we need to keep processing the
|
||||||
)
|
# frame. If we switched, we just swallow the frame.
|
||||||
await super().process_frame(service_switcher_filter_frame, direction)
|
if not service:
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# If we switched to a new service, request its metadata.
|
||||||
|
if service:
|
||||||
|
await service.queue_frame(RequestMetadataFrame())
|
||||||
|
else:
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|||||||
@@ -664,10 +664,16 @@ class LLMUserAggregator(LLMContextAggregator):
|
|||||||
await self._queued_broadcast_frame(frame_cls, **kwargs)
|
await self._queued_broadcast_frame(frame_cls, **kwargs)
|
||||||
|
|
||||||
async def _on_vad_speech_started(self, controller):
|
async def _on_vad_speech_started(self, controller):
|
||||||
await self._queued_broadcast_frame(VADUserStartedSpeakingFrame)
|
await self._queued_broadcast_frame(
|
||||||
|
VADUserStartedSpeakingFrame,
|
||||||
|
start_secs=controller._vad_analyzer.params.start_secs,
|
||||||
|
)
|
||||||
|
|
||||||
async def _on_vad_speech_stopped(self, controller):
|
async def _on_vad_speech_stopped(self, controller):
|
||||||
await self._queued_broadcast_frame(VADUserStoppedSpeakingFrame)
|
await self._queued_broadcast_frame(
|
||||||
|
VADUserStoppedSpeakingFrame,
|
||||||
|
stop_secs=controller._vad_analyzer.params.stop_secs,
|
||||||
|
)
|
||||||
|
|
||||||
async def _on_vad_speech_activity(self, controller):
|
async def _on_vad_speech_activity(self, controller):
|
||||||
await self._queued_broadcast_frame(UserSpeakingFrame)
|
await self._queued_broadcast_frame(UserSpeakingFrame)
|
||||||
|
|||||||
@@ -64,12 +64,18 @@ class VADProcessor(FrameProcessor):
|
|||||||
@self._vad_controller.event_handler("on_speech_started")
|
@self._vad_controller.event_handler("on_speech_started")
|
||||||
async def on_speech_started(_controller):
|
async def on_speech_started(_controller):
|
||||||
logger.debug(f"{self}: User started speaking")
|
logger.debug(f"{self}: User started speaking")
|
||||||
await self.broadcast_frame(VADUserStartedSpeakingFrame)
|
await self.broadcast_frame(
|
||||||
|
VADUserStartedSpeakingFrame,
|
||||||
|
start_secs=_controller._vad_analyzer.params.start_secs,
|
||||||
|
)
|
||||||
|
|
||||||
@self._vad_controller.event_handler("on_speech_stopped")
|
@self._vad_controller.event_handler("on_speech_stopped")
|
||||||
async def on_speech_stopped(_controller):
|
async def on_speech_stopped(_controller):
|
||||||
logger.debug(f"{self}: User stopped speaking")
|
logger.debug(f"{self}: User stopped speaking")
|
||||||
await self.broadcast_frame(VADUserStoppedSpeakingFrame)
|
await self.broadcast_frame(
|
||||||
|
VADUserStoppedSpeakingFrame,
|
||||||
|
stop_secs=_controller._vad_analyzer.params.stop_secs,
|
||||||
|
)
|
||||||
|
|
||||||
@self._vad_controller.event_handler("on_speech_activity")
|
@self._vad_controller.event_handler("on_speech_activity")
|
||||||
async def on_speech_activity(_controller):
|
async def on_speech_activity(_controller):
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ This module provides a processor that filters frames based on a custom function,
|
|||||||
allowing for flexible frame filtering logic in processing pipelines.
|
allowing for flexible frame filtering logic in processing pipelines.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Awaitable, Callable
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
from pipecat.frames.frames import CancelFrame, EndFrame, Frame, StartFrame, SystemFrame
|
from pipecat.frames.frames import CancelFrame, EndFrame, Frame, StartFrame, SystemFrame
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
|
||||||
|
FilterType = Callable[[Frame], Awaitable[bool]]
|
||||||
|
|
||||||
|
|
||||||
class FunctionFilter(FrameProcessor):
|
class FunctionFilter(FrameProcessor):
|
||||||
"""A frame processor that filters frames using a custom function.
|
"""A frame processor that filters frames using a custom function.
|
||||||
@@ -26,9 +28,10 @@ class FunctionFilter(FrameProcessor):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
filter: Callable[[Frame], Awaitable[bool]],
|
filter: FilterType,
|
||||||
direction: FrameDirection = FrameDirection.DOWNSTREAM,
|
direction: Optional[FrameDirection] = FrameDirection.DOWNSTREAM,
|
||||||
filter_system_frames: bool = False,
|
filter_system_frames: bool = False,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the function filter.
|
"""Initialize the function filter.
|
||||||
|
|
||||||
@@ -36,10 +39,13 @@ class FunctionFilter(FrameProcessor):
|
|||||||
filter: An async function that takes a Frame and returns True if the
|
filter: An async function that takes a Frame and returns True if the
|
||||||
frame should pass through, False otherwise.
|
frame should pass through, False otherwise.
|
||||||
direction: The direction to apply filtering. Only frames moving in
|
direction: The direction to apply filtering. Only frames moving in
|
||||||
this direction will be filtered. Defaults to DOWNSTREAM.
|
this direction will be filtered; frames in the other direction
|
||||||
|
pass through unfiltered. If None, frames in both directions
|
||||||
|
are filtered. Defaults to DOWNSTREAM.
|
||||||
filter_system_frames: Whether to filter system frames. Defaults to False.
|
filter_system_frames: Whether to filter system frames. Defaults to False.
|
||||||
|
**kwargs: Additional arguments passed to parent class.
|
||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__(**kwargs)
|
||||||
self._filter = filter
|
self._filter = filter
|
||||||
self._direction = direction
|
self._direction = direction
|
||||||
self._filter_system_frames = filter_system_frames
|
self._filter_system_frames = filter_system_frames
|
||||||
@@ -51,7 +57,7 @@ class FunctionFilter(FrameProcessor):
|
|||||||
def _should_passthrough_frame(self, frame, direction):
|
def _should_passthrough_frame(self, frame, direction):
|
||||||
"""Check if a frame should pass through without filtering."""
|
"""Check if a frame should pass through without filtering."""
|
||||||
# Always passthrough frames in the wrong direction
|
# Always passthrough frames in the wrong direction
|
||||||
if direction != self._direction:
|
if self._direction and direction != self._direction:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Always passthrough lifecycle frames
|
# Always passthrough lifecycle frames
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ WebSocket API for streaming audio transcription.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Any, AsyncGenerator, Dict
|
from typing import Any, AsyncGenerator, Dict, Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -29,6 +29,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import ASSEMBLYAI_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -67,6 +68,7 @@ class AssemblyAISTTService(WebsocketSTTService):
|
|||||||
api_endpoint_base_url: str = "wss://streaming.assemblyai.com/v3/ws",
|
api_endpoint_base_url: str = "wss://streaming.assemblyai.com/v3/ws",
|
||||||
connection_params: AssemblyAIConnectionParams = AssemblyAIConnectionParams(),
|
connection_params: AssemblyAIConnectionParams = AssemblyAIConnectionParams(),
|
||||||
vad_force_turn_endpoint: bool = True,
|
vad_force_turn_endpoint: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = ASSEMBLYAI_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the AssemblyAI STT service.
|
"""Initialize the AssemblyAI STT service.
|
||||||
@@ -77,9 +79,13 @@ class AssemblyAISTTService(WebsocketSTTService):
|
|||||||
api_endpoint_base_url: WebSocket endpoint URL. Defaults to AssemblyAI's streaming endpoint.
|
api_endpoint_base_url: WebSocket endpoint URL. Defaults to AssemblyAI's streaming endpoint.
|
||||||
connection_params: Connection configuration parameters. Defaults to AssemblyAIConnectionParams().
|
connection_params: Connection configuration parameters. Defaults to AssemblyAIConnectionParams().
|
||||||
vad_force_turn_endpoint: Whether to force turn endpoint on VAD stop. Defaults to True.
|
vad_force_turn_endpoint: Whether to force turn endpoint on VAD stop. Defaults to True.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent STTService class.
|
**kwargs: Additional arguments passed to parent STTService class.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=connection_params.sample_rate, **kwargs)
|
super().__init__(
|
||||||
|
sample_rate=connection_params.sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._language = language
|
self._language = language
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from pipecat.frames.frames import (
|
|||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
)
|
)
|
||||||
from pipecat.services.aws.utils import build_event_message, decode_event, get_presigned_url
|
from pipecat.services.aws.utils import build_event_message, decode_event, get_presigned_url
|
||||||
|
from pipecat.services.stt_latency import AWS_TRANSCRIBE_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -59,6 +60,7 @@ class AWSTranscribeSTTService(WebsocketSTTService):
|
|||||||
region: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
sample_rate: int = 16000,
|
sample_rate: int = 16000,
|
||||||
language: Language = Language.EN,
|
language: Language = Language.EN,
|
||||||
|
ttfs_p99_latency: Optional[float] = AWS_TRANSCRIBE_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the AWS Transcribe STT service.
|
"""Initialize the AWS Transcribe STT service.
|
||||||
@@ -70,9 +72,11 @@ class AWSTranscribeSTTService(WebsocketSTTService):
|
|||||||
region: AWS region for the service.
|
region: AWS region for the service.
|
||||||
sample_rate: Audio sample rate in Hz. Must be 8000 or 16000. Defaults to 16000.
|
sample_rate: Audio sample rate in Hz. Must be 8000 or 16000. Defaults to 16000.
|
||||||
language: Language for transcription. Defaults to English.
|
language: Language for transcription. Defaults to English.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent STTService class.
|
**kwargs: Additional arguments passed to parent STTService class.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"sample_rate": sample_rate,
|
"sample_rate": sample_rate,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from pipecat.frames.frames import (
|
|||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
)
|
)
|
||||||
from pipecat.services.azure.common import language_to_azure_language
|
from pipecat.services.azure.common import language_to_azure_language
|
||||||
|
from pipecat.services.stt_latency import AZURE_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -63,6 +64,7 @@ class AzureSTTService(STTService):
|
|||||||
language: Language = Language.EN_US,
|
language: Language = Language.EN_US,
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
endpoint_id: Optional[str] = None,
|
endpoint_id: Optional[str] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = AZURE_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Azure STT service.
|
"""Initialize the Azure STT service.
|
||||||
@@ -73,9 +75,11 @@ class AzureSTTService(STTService):
|
|||||||
language: Language for speech recognition. Defaults to English (US).
|
language: Language for speech recognition. Defaults to English (US).
|
||||||
sample_rate: Audio sample rate in Hz. If None, uses service default.
|
sample_rate: Audio sample rate in Hz. If None, uses service default.
|
||||||
endpoint_id: Custom model endpoint id.
|
endpoint_id: Custom model endpoint id.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent STTService.
|
**kwargs: Additional arguments passed to parent STTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
self._speech_config = SpeechConfig(
|
self._speech_config = SpeechConfig(
|
||||||
subscription=api_key,
|
subscription=api_key,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import CARTESIA_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -137,6 +138,7 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
base_url: str = "",
|
base_url: str = "",
|
||||||
sample_rate: int = 16000,
|
sample_rate: int = 16000,
|
||||||
live_options: Optional[CartesiaLiveOptions] = None,
|
live_options: Optional[CartesiaLiveOptions] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = CARTESIA_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize CartesiaSTTService with API key and options.
|
"""Initialize CartesiaSTTService with API key and options.
|
||||||
@@ -146,10 +148,12 @@ class CartesiaSTTService(WebsocketSTTService):
|
|||||||
base_url: Custom API endpoint URL. If empty, uses default.
|
base_url: Custom API endpoint URL. If empty, uses default.
|
||||||
sample_rate: Audio sample rate in Hz. Defaults to 16000.
|
sample_rate: Audio sample rate in Hz. Defaults to 16000.
|
||||||
live_options: Configuration options for transcription service.
|
live_options: Configuration options for transcription service.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent STTService.
|
**kwargs: Additional arguments passed to parent STTService.
|
||||||
"""
|
"""
|
||||||
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
default_options = CartesiaLiveOptions(
|
default_options = CartesiaLiveOptions(
|
||||||
model="ink-whisper",
|
model="ink-whisper",
|
||||||
|
|||||||
@@ -161,7 +161,11 @@ class DeepgramFluxSTTService(WebsocketSTTService):
|
|||||||
# was never destroyed.
|
# was never destroyed.
|
||||||
# So we can keep it here as false, because inside the method send_with_retry, it will
|
# So we can keep it here as false, because inside the method send_with_retry, it will
|
||||||
# already try to reconnect if needed.
|
# already try to reconnect if needed.
|
||||||
super().__init__(sample_rate=sample_rate, reconnect_on_error=False, **kwargs)
|
super().__init__(
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
reconnect_on_error=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._url = url
|
self._url = url
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import DEEPGRAM_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -61,6 +62,7 @@ class DeepgramSTTService(STTService):
|
|||||||
live_options: Optional[LiveOptions] = None,
|
live_options: Optional[LiveOptions] = None,
|
||||||
addons: Optional[Dict] = None,
|
addons: Optional[Dict] = None,
|
||||||
should_interrupt: bool = True,
|
should_interrupt: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = DEEPGRAM_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Deepgram STT service.
|
"""Initialize the Deepgram STT service.
|
||||||
@@ -81,13 +83,15 @@ class DeepgramSTTService(STTService):
|
|||||||
.. deprecated:: 0.0.99
|
.. deprecated:: 0.0.99
|
||||||
This parameter will be removed along with `vad_events` support.
|
This parameter will be removed along with `vad_events` support.
|
||||||
|
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the parent STTService.
|
**kwargs: Additional arguments passed to the parent STTService.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
The `vad_events` option in LiveOptions is deprecated as of version 0.0.99 and will be removed in a future version. Please use the Silero VAD instead.
|
The `vad_events` option in LiveOptions is deprecated as of version 0.0.99 and will be removed in a future version. Please use the Silero VAD instead.
|
||||||
"""
|
"""
|
||||||
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
if url:
|
if url:
|
||||||
import warnings
|
import warnings
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from pipecat.frames.frames import (
|
|||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
|
from pipecat.services.aws.sagemaker.bidi_client import SageMakerBidiClient
|
||||||
|
from pipecat.services.stt_latency import DEEPGRAM_SAGEMAKER_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -81,6 +82,7 @@ class DeepgramSageMakerSTTService(STTService):
|
|||||||
region: str,
|
region: str,
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
live_options: Optional[LiveOptions] = None,
|
live_options: Optional[LiveOptions] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = DEEPGRAM_SAGEMAKER_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Deepgram SageMaker STT service.
|
"""Initialize the Deepgram SageMaker STT service.
|
||||||
@@ -93,10 +95,12 @@ class DeepgramSageMakerSTTService(STTService):
|
|||||||
live_options or defaults to the value from StartFrame.
|
live_options or defaults to the value from StartFrame.
|
||||||
live_options: Deepgram LiveOptions for detailed configuration. If None,
|
live_options: Deepgram LiveOptions for detailed configuration. If None,
|
||||||
uses sensible defaults (nova-3 model, English, interim results enabled).
|
uses sensible defaults (nova-3 model, English, interim results enabled).
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the parent STTService.
|
**kwargs: Additional arguments passed to the parent STTService.
|
||||||
"""
|
"""
|
||||||
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
sample_rate = sample_rate or (live_options.sample_rate if live_options else None)
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
self._endpoint_name = endpoint_name
|
self._endpoint_name = endpoint_name
|
||||||
self._region = region
|
self._region = region
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import ELEVENLABS_REALTIME_TTFS_P99, ELEVENLABS_TTFS_P99
|
||||||
from pipecat.services.stt_service import SegmentedSTTService, WebsocketSTTService
|
from pipecat.services.stt_service import SegmentedSTTService, WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -194,6 +195,7 @@ class ElevenLabsSTTService(SegmentedSTTService):
|
|||||||
model: str = "scribe_v2",
|
model: str = "scribe_v2",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = ELEVENLABS_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the ElevenLabs STT service.
|
"""Initialize the ElevenLabs STT service.
|
||||||
@@ -205,10 +207,13 @@ class ElevenLabsSTTService(SegmentedSTTService):
|
|||||||
model: Model ID for transcription. Defaults to "scribe_v2".
|
model: Model ID for transcription. Defaults to "scribe_v2".
|
||||||
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
||||||
params: Configuration parameters for the STT service.
|
params: Configuration parameters for the STT service.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to SegmentedSTTService.
|
**kwargs: Additional arguments passed to SegmentedSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,6 +441,7 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
model: str = "scribe_v2_realtime",
|
model: str = "scribe_v2_realtime",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = ELEVENLABS_REALTIME_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the ElevenLabs Realtime STT service.
|
"""Initialize the ElevenLabs Realtime STT service.
|
||||||
@@ -446,10 +452,13 @@ class ElevenLabsRealtimeSTTService(WebsocketSTTService):
|
|||||||
model: Model ID for transcription. Defaults to "scribe_v2_realtime".
|
model: Model ID for transcription. Defaults to "scribe_v2_realtime".
|
||||||
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
||||||
params: Configuration parameters for the STT service.
|
params: Configuration parameters for the STT service.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to WebsocketSTTService.
|
**kwargs: Additional arguments passed to WebsocketSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from loguru import logger
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame
|
from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame
|
||||||
|
from pipecat.services.stt_latency import FAL_TTFS_P99
|
||||||
from pipecat.services.stt_service import SegmentedSTTService
|
from pipecat.services.stt_service import SegmentedSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -173,6 +174,7 @@ class FalSTTService(SegmentedSTTService):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = FAL_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the FalSTTService with API key and parameters.
|
"""Initialize the FalSTTService with API key and parameters.
|
||||||
@@ -181,10 +183,13 @@ class FalSTTService(SegmentedSTTService):
|
|||||||
api_key: Fal API key. If not provided, will check FAL_KEY environment variable.
|
api_key: Fal API key. If not provided, will check FAL_KEY environment variable.
|
||||||
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate.
|
||||||
params: Configuration parameters for the Wizper API.
|
params: Configuration parameters for the Wizper API.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to SegmentedSTTService.
|
**kwargs: Additional arguments passed to SegmentedSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from pipecat.frames.frames import (
|
|||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.services.gladia.config import GladiaInputParams
|
from pipecat.services.gladia.config import GladiaInputParams
|
||||||
|
from pipecat.services.stt_latency import GLADIA_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -205,6 +206,7 @@ class GladiaSTTService(WebsocketSTTService):
|
|||||||
params: Optional[GladiaInputParams] = None,
|
params: Optional[GladiaInputParams] = None,
|
||||||
max_buffer_size: int = 1024 * 1024 * 20, # 20MB default buffer
|
max_buffer_size: int = 1024 * 1024 * 20, # 20MB default buffer
|
||||||
should_interrupt: bool = True,
|
should_interrupt: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = GLADIA_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Gladia STT service.
|
"""Initialize the Gladia STT service.
|
||||||
@@ -225,9 +227,11 @@ class GladiaSTTService(WebsocketSTTService):
|
|||||||
max_buffer_size: Maximum size of audio buffer in bytes. Defaults to 20MB.
|
max_buffer_size: Maximum size of audio buffer in bytes. Defaults to 20MB.
|
||||||
should_interrupt: Determine whether the bot should be interrupted when
|
should_interrupt: Determine whether the bot should be interrupted when
|
||||||
Gladia VAD detects user speech. Defaults to True.
|
Gladia VAD detects user speech. Defaults to True.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the STTService parent class.
|
**kwargs: Additional arguments passed to the STTService parent class.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
params = params or GladiaInputParams()
|
params = params or GladiaInputParams()
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from pipecat.frames.frames import (
|
|||||||
StartFrame,
|
StartFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
)
|
)
|
||||||
|
from pipecat.services.stt_latency import GOOGLE_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -438,6 +439,7 @@ class GoogleSTTService(STTService):
|
|||||||
location: str = "global",
|
location: str = "global",
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = GOOGLE_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Google STT service.
|
"""Initialize the Google STT service.
|
||||||
@@ -448,9 +450,11 @@ class GoogleSTTService(STTService):
|
|||||||
location: Google Cloud location (e.g., "global", "us-central1").
|
location: Google Cloud location (e.g., "global", "us-central1").
|
||||||
sample_rate: Audio sample rate in Hertz.
|
sample_rate: Audio sample rate in Hertz.
|
||||||
params: Configuration parameters for the service.
|
params: Configuration parameters for the service.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to STTService.
|
**kwargs: Additional arguments passed to STTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
params = params or GoogleSTTService.InputParams()
|
params = params or GoogleSTTService.InputParams()
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import GRADIUM_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -94,6 +95,7 @@ class GradiumSTTService(WebsocketSTTService):
|
|||||||
api_endpoint_base_url: str = "wss://eu.api.gradium.ai/api/speech/asr",
|
api_endpoint_base_url: str = "wss://eu.api.gradium.ai/api/speech/asr",
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
json_config: Optional[str] = None,
|
json_config: Optional[str] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = GRADIUM_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Gradium STT service.
|
"""Initialize the Gradium STT service.
|
||||||
@@ -107,9 +109,11 @@ class GradiumSTTService(WebsocketSTTService):
|
|||||||
.. deprecated:: 0.0.101
|
.. deprecated:: 0.0.101
|
||||||
Use `params` instead for type-safe configuration.
|
Use `params` instead for type-safe configuration.
|
||||||
|
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent STTService class.
|
**kwargs: Additional arguments passed to parent STTService class.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=SAMPLE_RATE, **kwargs)
|
super().__init__(sample_rate=SAMPLE_RATE, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
if json_config is not None:
|
if json_config is not None:
|
||||||
import warnings
|
import warnings
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pipecat.services.stt_latency import GROQ_TTFS_P99
|
||||||
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class GroqSTTService(BaseWhisperSTTService):
|
|||||||
language: Optional[Language] = Language.EN,
|
language: Optional[Language] = Language.EN,
|
||||||
prompt: Optional[str] = None,
|
prompt: Optional[str] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = GROQ_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize Groq STT service.
|
"""Initialize Groq STT service.
|
||||||
@@ -39,6 +41,8 @@ class GroqSTTService(BaseWhisperSTTService):
|
|||||||
language: Language of the audio input. Defaults to English.
|
language: Language of the audio input. Defaults to English.
|
||||||
prompt: Optional text to guide the model's style or continue a previous segment.
|
prompt: Optional text to guide the model's style or continue a previous segment.
|
||||||
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to BaseWhisperSTTService.
|
**kwargs: Additional arguments passed to BaseWhisperSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -48,6 +52,7 @@ class GroqSTTService(BaseWhisperSTTService):
|
|||||||
language=language,
|
language=language,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from pipecat.frames.frames import (
|
|||||||
Frame,
|
Frame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
)
|
)
|
||||||
|
from pipecat.services.stt_latency import HATHORA_TTFS_P99
|
||||||
from pipecat.services.stt_service import SegmentedSTTService
|
from pipecat.services.stt_service import SegmentedSTTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -53,6 +54,7 @@ class HathoraSTTService(SegmentedSTTService):
|
|||||||
api_key: Optional[str] = None,
|
api_key: Optional[str] = None,
|
||||||
base_url: str = "https://api.models.hathora.dev/inference/v1/stt",
|
base_url: str = "https://api.models.hathora.dev/inference/v1/stt",
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = HATHORA_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Hathora STT service.
|
"""Initialize the Hathora STT service.
|
||||||
@@ -66,10 +68,13 @@ class HathoraSTTService(SegmentedSTTService):
|
|||||||
provision one [here](https://models.hathora.dev/tokens).
|
provision one [here](https://models.hathora.dev/tokens).
|
||||||
base_url: Base API URL for the Hathora STT service.
|
base_url: Base API URL for the Hathora STT service.
|
||||||
params: Configuration parameters.
|
params: Configuration parameters.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the parent class.
|
**kwargs: Additional arguments passed to the parent class.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
self._model = model
|
self._model = model
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from pipecat.frames.frames import (
|
|||||||
StartFrame,
|
StartFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
)
|
)
|
||||||
|
from pipecat.services.stt_latency import NVIDIA_TTFS_P99
|
||||||
from pipecat.services.stt_service import SegmentedSTTService, STTService
|
from pipecat.services.stt_service import SegmentedSTTService, STTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -117,6 +118,7 @@ class NvidiaSTTService(STTService):
|
|||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
use_ssl: bool = True,
|
use_ssl: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = NVIDIA_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the NVIDIA Riva STT service.
|
"""Initialize the NVIDIA Riva STT service.
|
||||||
@@ -128,9 +130,11 @@ class NvidiaSTTService(STTService):
|
|||||||
sample_rate: Audio sample rate in Hz. If None, uses pipeline default.
|
sample_rate: Audio sample rate in Hz. If None, uses pipeline default.
|
||||||
params: Additional configuration parameters for NVIDIA Riva.
|
params: Additional configuration parameters for NVIDIA Riva.
|
||||||
use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True.
|
use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to STTService.
|
**kwargs: Additional arguments passed to STTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
params = params or NvidiaSTTService.InputParams()
|
params = params or NvidiaSTTService.InputParams()
|
||||||
|
|
||||||
@@ -413,6 +417,7 @@ class NvidiaSegmentedSTTService(SegmentedSTTService):
|
|||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
use_ssl: bool = True,
|
use_ssl: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = NVIDIA_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the NVIDIA Riva segmented STT service.
|
"""Initialize the NVIDIA Riva segmented STT service.
|
||||||
@@ -424,9 +429,11 @@ class NvidiaSegmentedSTTService(SegmentedSTTService):
|
|||||||
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate
|
sample_rate: Audio sample rate in Hz. If not provided, uses the pipeline's rate
|
||||||
params: Additional configuration parameters for NVIDIA Riva
|
params: Additional configuration parameters for NVIDIA Riva
|
||||||
use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True.
|
use_ssl: Whether to use SSL for the NVIDIA Riva server. Defaults to True.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to SegmentedSTTService
|
**kwargs: Additional arguments passed to SegmentedSTTService
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
params = params or NvidiaSegmentedSTTService.InputParams()
|
params = params or NvidiaSegmentedSTTService.InputParams()
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import OPENAI_REALTIME_TTFS_P99, OPENAI_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
@@ -64,6 +65,7 @@ class OpenAISTTService(BaseWhisperSTTService):
|
|||||||
language: Optional[Language] = Language.EN,
|
language: Optional[Language] = Language.EN,
|
||||||
prompt: Optional[str] = None,
|
prompt: Optional[str] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = OPENAI_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize OpenAI STT service.
|
"""Initialize OpenAI STT service.
|
||||||
@@ -75,6 +77,8 @@ class OpenAISTTService(BaseWhisperSTTService):
|
|||||||
language: Language of the audio input. Defaults to English.
|
language: Language of the audio input. Defaults to English.
|
||||||
prompt: Optional text to guide the model's style or continue a previous segment.
|
prompt: Optional text to guide the model's style or continue a previous segment.
|
||||||
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to BaseWhisperSTTService.
|
**kwargs: Additional arguments passed to BaseWhisperSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -84,6 +88,7 @@ class OpenAISTTService(BaseWhisperSTTService):
|
|||||||
language=language,
|
language=language,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +167,7 @@ class OpenAIRealtimeSTTService(WebsocketSTTService):
|
|||||||
turn_detection: Optional[Union[dict, Literal[False]]] = False,
|
turn_detection: Optional[Union[dict, Literal[False]]] = False,
|
||||||
noise_reduction: Optional[Literal["near_field", "far_field"]] = None,
|
noise_reduction: Optional[Literal["near_field", "far_field"]] = None,
|
||||||
should_interrupt: bool = True,
|
should_interrupt: bool = True,
|
||||||
|
ttfs_p99_latency: Optional[float] = OPENAI_REALTIME_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the OpenAI Realtime STT service.
|
"""Initialize the OpenAI Realtime STT service.
|
||||||
@@ -187,6 +193,8 @@ class OpenAIRealtimeSTTService(WebsocketSTTService):
|
|||||||
should_interrupt: Whether to interrupt bot output when
|
should_interrupt: Whether to interrupt bot output when
|
||||||
speech is detected by server-side VAD. Only applies when
|
speech is detected by server-side VAD. Only applies when
|
||||||
turn detection is enabled. Defaults to True.
|
turn detection is enabled. Defaults to True.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to parent
|
**kwargs: Additional arguments passed to parent
|
||||||
WebsocketSTTService.
|
WebsocketSTTService.
|
||||||
"""
|
"""
|
||||||
@@ -196,7 +204,10 @@ class OpenAIRealtimeSTTService(WebsocketSTTService):
|
|||||||
"Install it with: pip install pipecat-ai[openai]"
|
"Install it with: pip install pipecat-ai[openai]"
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.services.stt_latency import SAMBANOVA_TTFS_P99
|
||||||
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
from pipecat.services.whisper.base_stt import BaseWhisperSTTService, Transcription
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore
|
|||||||
language: Optional[Language] = Language.EN,
|
language: Optional[Language] = Language.EN,
|
||||||
prompt: Optional[str] = None,
|
prompt: Optional[str] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = SAMBANOVA_TTFS_P99,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize SambaNova STT service.
|
"""Initialize SambaNova STT service.
|
||||||
@@ -41,6 +43,8 @@ class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore
|
|||||||
language: Language of the audio input. Defaults to English.
|
language: Language of the audio input. Defaults to English.
|
||||||
prompt: Optional text to guide the model's style or continue a previous segment.
|
prompt: Optional text to guide the model's style or continue a previous segment.
|
||||||
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
temperature: Optional sampling temperature between 0 and 1. Defaults to 0.0.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to `pipecat.services.whisper.base_stt.BaseWhisperSTTService`.
|
**kwargs: Additional arguments passed to `pipecat.services.whisper.base_stt.BaseWhisperSTTService`.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -50,6 +54,7 @@ class SambaNovaSTTService(BaseWhisperSTTService): # type: ignore
|
|||||||
language=language,
|
language=language,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
ttfs_p99_latency=ttfs_p99_latency,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from pipecat.frames.frames import (
|
|||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.sarvam._sdk import sdk_headers
|
from pipecat.services.sarvam._sdk import sdk_headers
|
||||||
|
from pipecat.services.stt_latency import SARVAM_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -159,6 +160,7 @@ class SarvamSTTService(STTService):
|
|||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
input_audio_codec: str = "wav",
|
input_audio_codec: str = "wav",
|
||||||
params: Optional[InputParams] = None,
|
params: Optional[InputParams] = None,
|
||||||
|
ttfs_p99_latency: Optional[float] = SARVAM_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Sarvam STT service.
|
"""Initialize the Sarvam STT service.
|
||||||
@@ -172,6 +174,8 @@ class SarvamSTTService(STTService):
|
|||||||
sample_rate: Audio sample rate. Defaults to 16000 if not specified.
|
sample_rate: Audio sample rate. Defaults to 16000 if not specified.
|
||||||
input_audio_codec: Audio codec/format of the input file. Defaults to "wav".
|
input_audio_codec: Audio codec/format of the input file. Defaults to "wav".
|
||||||
params: Configuration parameters for Sarvam STT service.
|
params: Configuration parameters for Sarvam STT service.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the parent STTService.
|
**kwargs: Additional arguments passed to the parent STTService.
|
||||||
"""
|
"""
|
||||||
params = params or SarvamSTTService.InputParams()
|
params = params or SarvamSTTService.InputParams()
|
||||||
@@ -193,7 +197,7 @@ class SarvamSTTService(STTService):
|
|||||||
f"Model '{model}' does not support language parameter (auto-detects language)."
|
f"Model '{model}' does not support language parameter (auto-detects language)."
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
self.set_model_name(model)
|
self.set_model_name(model)
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import SONIOX_TTFS_P99
|
||||||
from pipecat.services.stt_service import WebsocketSTTService
|
from pipecat.services.stt_service import WebsocketSTTService
|
||||||
from pipecat.transcriptions.language import Language
|
from pipecat.transcriptions.language import Language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -152,6 +153,7 @@ class SonioxSTTService(WebsocketSTTService):
|
|||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
params: Optional[SonioxInputParams] = None,
|
params: Optional[SonioxInputParams] = None,
|
||||||
vad_force_turn_endpoint: bool = False,
|
vad_force_turn_endpoint: bool = False,
|
||||||
|
ttfs_p99_latency: Optional[float] = SONIOX_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Soniox STT service.
|
"""Initialize the Soniox STT service.
|
||||||
@@ -163,9 +165,11 @@ class SonioxSTTService(WebsocketSTTService):
|
|||||||
params: Additional configuration parameters, such as language hints, context and
|
params: Additional configuration parameters, such as language hints, context and
|
||||||
speaker diarization.
|
speaker diarization.
|
||||||
vad_force_turn_endpoint: Listen to `VADUserStoppedSpeakingFrame` to send finalize message to Soniox. If disabled, Soniox will detect the end of the speech.
|
vad_force_turn_endpoint: Listen to `VADUserStoppedSpeakingFrame` to send finalize message to Soniox. If disabled, Soniox will detect the end of the speech.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to the STTService.
|
**kwargs: Additional arguments passed to the STTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
params = params or SonioxInputParams()
|
params = params or SonioxInputParams()
|
||||||
|
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from pipecat.frames.frames import (
|
|||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
|
from pipecat.services.stt_latency import SPEECHMATICS_TTFS_P99
|
||||||
from pipecat.services.stt_service import STTService
|
from pipecat.services.stt_service import STTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.tracing.service_decorators import traced_stt
|
from pipecat.utils.tracing.service_decorators import traced_stt
|
||||||
@@ -288,6 +289,7 @@ class SpeechmaticsSTTService(STTService):
|
|||||||
sample_rate: int | None = None,
|
sample_rate: int | None = None,
|
||||||
params: InputParams | None = None,
|
params: InputParams | None = None,
|
||||||
should_interrupt: bool = True,
|
should_interrupt: bool = True,
|
||||||
|
ttfs_p99_latency: float | None = SPEECHMATICS_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Speechmatics STT service.
|
"""Initialize the Speechmatics STT service.
|
||||||
@@ -300,9 +302,11 @@ class SpeechmaticsSTTService(STTService):
|
|||||||
sample_rate: Optional audio sample rate in Hz.
|
sample_rate: Optional audio sample rate in Hz.
|
||||||
params: Optional[InputParams]: Input parameters for the service.
|
params: Optional[InputParams]: Input parameters for the service.
|
||||||
should_interrupt: Determine whether the bot should be interrupted when Speechmatics turn_detection_mode is configured to detect user speech.
|
should_interrupt: Determine whether the bot should be interrupted when Speechmatics turn_detection_mode is configured to detect user speech.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to STTService.
|
**kwargs: Additional arguments passed to STTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(sample_rate=sample_rate, **kwargs)
|
super().__init__(sample_rate=sample_rate, ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
|
|
||||||
# Service parameters
|
# Service parameters
|
||||||
self._api_key: str = api_key or os.getenv("SPEECHMATICS_API_KEY")
|
self._api_key: str = api_key or os.getenv("SPEECHMATICS_API_KEY")
|
||||||
|
|||||||
53
src/pipecat/services/stt_latency.py
Normal file
53
src/pipecat/services/stt_latency.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2026, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""STT service latency defaults.
|
||||||
|
|
||||||
|
This module contains P99 time-to-final-segment (TTFS) latency values for STT
|
||||||
|
services. TTFS measures the time from when speech ends to when the final
|
||||||
|
transcript is received.
|
||||||
|
|
||||||
|
These values are used by turn stop strategies to optimize timing. Each STT
|
||||||
|
service publishes its latency via STTMetadataFrame at pipeline start.
|
||||||
|
|
||||||
|
To measure latency for your specific deployment (region, network conditions,
|
||||||
|
self-hosted instances), use the STT benchmark tool:
|
||||||
|
https://github.com/pipecat-ai/stt-benchmark
|
||||||
|
|
||||||
|
Run the TTFS benchmark for your service and configuration, then pass the
|
||||||
|
measured value to your STT service constructor:
|
||||||
|
|
||||||
|
stt = DeepgramSTTService(api_key="...", ttfs_p99_latency=0.45)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Conservative fallback for services without measured values
|
||||||
|
DEFAULT_TTFS_P99: float = 1.0
|
||||||
|
|
||||||
|
# Measured P99 TTFS latency values (in seconds)
|
||||||
|
ASSEMBLYAI_TTFS_P99: float = 0.42
|
||||||
|
AWS_TRANSCRIBE_TTFS_P99: float = 1.90
|
||||||
|
AZURE_TTFS_P99: float = 1.80
|
||||||
|
CARTESIA_TTFS_P99: float = 0.81
|
||||||
|
DEEPGRAM_TTFS_P99: float = 0.35
|
||||||
|
DEEPGRAM_SAGEMAKER_TTFS_P99: float = 0.35
|
||||||
|
ELEVENLABS_TTFS_P99: float = 2.01
|
||||||
|
ELEVENLABS_REALTIME_TTFS_P99: float = 0.41
|
||||||
|
FAL_TTFS_P99: float = 2.07
|
||||||
|
GLADIA_TTFS_P99: float = 1.49
|
||||||
|
GOOGLE_TTFS_P99: float = 1.57
|
||||||
|
GRADIUM_TTFS_P99: float = 1.61
|
||||||
|
GROQ_TTFS_P99: float = 1.54
|
||||||
|
HATHORA_TTFS_P99: float = 0.87
|
||||||
|
OPENAI_TTFS_P99: float = 2.01
|
||||||
|
OPENAI_REALTIME_TTFS_P99: float = 1.66
|
||||||
|
SAMBANOVA_TTFS_P99: float = 2.20
|
||||||
|
SARVAM_TTFS_P99: float = 1.17
|
||||||
|
SONIOX_TTFS_P99: float = 0.35
|
||||||
|
SPEECHMATICS_TTFS_P99: float = 0.74
|
||||||
|
|
||||||
|
# These services run locally and should be replaced with measured values
|
||||||
|
NVIDIA_TTFS_P99: float = DEFAULT_TTFS_P99
|
||||||
|
WHISPER_TTFS_P99: float = DEFAULT_TTFS_P99
|
||||||
@@ -21,8 +21,9 @@ from pipecat.frames.frames import (
|
|||||||
Frame,
|
Frame,
|
||||||
InterruptionFrame,
|
InterruptionFrame,
|
||||||
MetricsFrame,
|
MetricsFrame,
|
||||||
SpeechControlParamsFrame,
|
RequestMetadataFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
|
STTMetadataFrame,
|
||||||
STTMuteFrame,
|
STTMuteFrame,
|
||||||
STTUpdateSettingsFrame,
|
STTUpdateSettingsFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
@@ -32,6 +33,7 @@ from pipecat.frames.frames import (
|
|||||||
from pipecat.metrics.metrics import TTFBMetricsData
|
from pipecat.metrics.metrics import TTFBMetricsData
|
||||||
from pipecat.processors.frame_processor import FrameDirection
|
from pipecat.processors.frame_processor import FrameDirection
|
||||||
from pipecat.services.ai_service import AIService
|
from pipecat.services.ai_service import AIService
|
||||||
|
from pipecat.services.stt_latency import DEFAULT_TTFS_P99
|
||||||
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
|
||||||
|
|
||||||
@@ -65,11 +67,11 @@ class STTService(AIService):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
audio_passthrough=True,
|
audio_passthrough=True,
|
||||||
# STT input sample rate
|
|
||||||
sample_rate: Optional[int] = None,
|
sample_rate: Optional[int] = None,
|
||||||
# STT TTFB timeout - time to wait after VAD stop before reporting TTFB
|
|
||||||
stt_ttfb_timeout: float = 2.0,
|
stt_ttfb_timeout: float = 2.0,
|
||||||
|
ttfs_p99_latency: Optional[float] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the STT service.
|
"""Initialize the STT service.
|
||||||
@@ -85,6 +87,10 @@ class STTService(AIService):
|
|||||||
request to first response byte). Since STT receives continuous audio, we measure
|
request to first response byte). Since STT receives continuous audio, we measure
|
||||||
from when the user stops speaking to when the final transcript arrives—capturing
|
from when the user stops speaking to when the final transcript arrives—capturing
|
||||||
the latency that matters for voice AI applications.
|
the latency that matters for voice AI applications.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
This is broadcast via STTMetadataFrame at pipeline start for downstream
|
||||||
|
processors (e.g., turn strategies) to optimize timing. Subclasses provide
|
||||||
|
measured defaults; pass a value here to override for your deployment.
|
||||||
**kwargs: Additional arguments passed to the parent AIService.
|
**kwargs: Additional arguments passed to the parent AIService.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
@@ -95,11 +101,11 @@ class STTService(AIService):
|
|||||||
self._tracing_enabled: bool = False
|
self._tracing_enabled: bool = False
|
||||||
self._muted: bool = False
|
self._muted: bool = False
|
||||||
self._user_id: str = ""
|
self._user_id: str = ""
|
||||||
|
self._ttfs_p99_latency = ttfs_p99_latency
|
||||||
|
|
||||||
# STT TTFB tracking state
|
# STT TTFB tracking state
|
||||||
self._stt_ttfb_timeout = stt_ttfb_timeout
|
self._stt_ttfb_timeout = stt_ttfb_timeout
|
||||||
self._ttfb_timeout_task: Optional[asyncio.Task] = None
|
self._ttfb_timeout_task: Optional[asyncio.Task] = None
|
||||||
self._vad_stop_secs: Optional[float] = None
|
|
||||||
self._speech_end_time: Optional[float] = None
|
self._speech_end_time: Optional[float] = None
|
||||||
self._user_speaking: bool = False
|
self._user_speaking: bool = False
|
||||||
self._last_transcription_time: Optional[float] = None
|
self._last_transcription_time: Optional[float] = None
|
||||||
@@ -254,16 +260,20 @@ class STTService(AIService):
|
|||||||
"""
|
"""
|
||||||
await super().process_frame(frame, direction)
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
if isinstance(frame, AudioRawFrame):
|
if isinstance(frame, StartFrame):
|
||||||
|
# Push StartFrame first, then metadata so downstream receives them in order
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
await self._push_stt_metadata()
|
||||||
|
elif isinstance(frame, RequestMetadataFrame):
|
||||||
|
# Don't push the RequestMetadataFrame, just push the metadata
|
||||||
|
await self._push_stt_metadata()
|
||||||
|
elif isinstance(frame, AudioRawFrame):
|
||||||
# In this service we accumulate audio internally and at the end we
|
# In this service we accumulate audio internally and at the end we
|
||||||
# push a TextFrame. We also push audio downstream in case someone
|
# push a TextFrame. We also push audio downstream in case someone
|
||||||
# else needs it.
|
# else needs it.
|
||||||
await self.process_audio_frame(frame, direction)
|
await self.process_audio_frame(frame, direction)
|
||||||
if self._audio_passthrough:
|
if self._audio_passthrough:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
elif isinstance(frame, SpeechControlParamsFrame):
|
|
||||||
await self._handle_speech_control_params(frame)
|
|
||||||
await self.push_frame(frame, direction)
|
|
||||||
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||||
await self._handle_vad_user_started_speaking(frame)
|
await self._handle_vad_user_started_speaking(frame)
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
@@ -314,14 +324,13 @@ class STTService(AIService):
|
|||||||
|
|
||||||
await super().push_frame(frame, direction)
|
await super().push_frame(frame, direction)
|
||||||
|
|
||||||
async def _handle_speech_control_params(self, frame: SpeechControlParamsFrame):
|
async def _push_stt_metadata(self):
|
||||||
"""Handle speech control parameters frame to extract VAD stop_secs.
|
"""Push STT metadata frame for downstream processors (e.g., turn strategies)."""
|
||||||
|
ttfs = self._ttfs_p99_latency
|
||||||
Args:
|
if ttfs is None:
|
||||||
frame: The speech control parameters frame.
|
ttfs = DEFAULT_TTFS_P99
|
||||||
"""
|
logger.warning(f"{self.name}: ttfs_p99_latency not set, using default {ttfs}s")
|
||||||
if frame.vad_params is not None:
|
await self.broadcast_frame(STTMetadataFrame, service_name=self.name, ttfs_p99_latency=ttfs)
|
||||||
self._vad_stop_secs = frame.vad_params.stop_secs
|
|
||||||
|
|
||||||
async def _cancel_ttfb_timeout(self):
|
async def _cancel_ttfb_timeout(self):
|
||||||
"""Cancel any pending TTFB timeout task."""
|
"""Cancel any pending TTFB timeout task."""
|
||||||
@@ -369,14 +378,14 @@ class STTService(AIService):
|
|||||||
"""
|
"""
|
||||||
self._user_speaking = False
|
self._user_speaking = False
|
||||||
|
|
||||||
# Skip TTFB measurement if we don't have VAD params
|
# Skip TTFB measurement if stop_secs is not set
|
||||||
if self._vad_stop_secs is None:
|
if frame.stop_secs == 0.0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Calculate the actual speech end time (current time minus VAD stop delay).
|
# Calculate the actual speech end time (current time minus VAD stop delay).
|
||||||
# This approximates when the last user audio was sent to the STT service,
|
# This approximates when the last user audio was sent to the STT service,
|
||||||
# which we use to measure against the eventual transcription response.
|
# which we use to measure against the eventual transcription response.
|
||||||
self._speech_end_time = time.time() - self._vad_stop_secs
|
self._speech_end_time = frame.timestamp - frame.stop_secs
|
||||||
|
|
||||||
# Start timeout task (any previous timeout was cancelled by VADUserStartedSpeakingFrame
|
# Start timeout task (any previous timeout was cancelled by VADUserStartedSpeakingFrame
|
||||||
# or InterruptionFrame)
|
# or InterruptionFrame)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from openai import AsyncOpenAI
|
|||||||
from openai.types.audio import Transcription
|
from openai.types.audio import Transcription
|
||||||
|
|
||||||
from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame
|
from pipecat.frames.frames import ErrorFrame, Frame, TranscriptionFrame
|
||||||
|
from pipecat.services.stt_latency import WHISPER_TTFS_P99
|
||||||
from pipecat.services.stt_service import SegmentedSTTService
|
from pipecat.services.stt_service import SegmentedSTTService
|
||||||
from pipecat.transcriptions.language import Language, resolve_language
|
from pipecat.transcriptions.language import Language, resolve_language
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -115,6 +116,7 @@ class BaseWhisperSTTService(SegmentedSTTService):
|
|||||||
prompt: Optional[str] = None,
|
prompt: Optional[str] = None,
|
||||||
temperature: Optional[float] = None,
|
temperature: Optional[float] = None,
|
||||||
include_prob_metrics: bool = False,
|
include_prob_metrics: bool = False,
|
||||||
|
ttfs_p99_latency: Optional[float] = WHISPER_TTFS_P99,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Initialize the Whisper STT service.
|
"""Initialize the Whisper STT service.
|
||||||
@@ -129,9 +131,11 @@ class BaseWhisperSTTService(SegmentedSTTService):
|
|||||||
include_prob_metrics: If True, enables probability metrics in API response.
|
include_prob_metrics: If True, enables probability metrics in API response.
|
||||||
Each service implements this differently (see child classes).
|
Each service implements this differently (see child classes).
|
||||||
Defaults to False.
|
Defaults to False.
|
||||||
|
ttfs_p99_latency: P99 latency from speech end to final transcript in seconds.
|
||||||
|
Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark
|
||||||
**kwargs: Additional arguments passed to SegmentedSTTService.
|
**kwargs: Additional arguments passed to SegmentedSTTService.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(ttfs_p99_latency=ttfs_p99_latency, **kwargs)
|
||||||
self.set_model_name(model)
|
self.set_model_name(model)
|
||||||
self._client = self._create_client(api_key, base_url)
|
self._client = self._create_client(api_key, base_url)
|
||||||
self._language = self.language_to_service_language(language or Language.EN)
|
self._language = self.language_to_service_language(language or Language.EN)
|
||||||
|
|||||||
@@ -464,7 +464,12 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
if self._params.turn_analyzer:
|
if self._params.turn_analyzer:
|
||||||
await self._deprecated_handle_user_interruption(VADState.QUIET)
|
await self._deprecated_handle_user_interruption(VADState.QUIET)
|
||||||
else:
|
else:
|
||||||
await self.push_frame(VADUserStoppedSpeakingFrame())
|
stop_secs = (
|
||||||
|
self._params.vad_analyzer.params.stop_secs
|
||||||
|
if self._params.vad_analyzer
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs))
|
||||||
###################################################################
|
###################################################################
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -492,9 +497,17 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
and new_vad_state != VADState.STOPPING
|
and new_vad_state != VADState.STOPPING
|
||||||
):
|
):
|
||||||
if new_vad_state == VADState.SPEAKING:
|
if new_vad_state == VADState.SPEAKING:
|
||||||
await self.push_frame(VADUserStartedSpeakingFrame())
|
start_secs = (
|
||||||
|
self._params.vad_analyzer.params.start_secs
|
||||||
|
if self._params.vad_analyzer
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
await self.push_frame(VADUserStartedSpeakingFrame(start_secs=start_secs))
|
||||||
elif new_vad_state == VADState.QUIET:
|
elif new_vad_state == VADState.QUIET:
|
||||||
await self.push_frame(VADUserStoppedSpeakingFrame())
|
stop_secs = (
|
||||||
|
self._params.vad_analyzer.params.stop_secs if self._params.vad_analyzer else 0.0
|
||||||
|
)
|
||||||
|
await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs))
|
||||||
|
|
||||||
vad_state = new_vad_state
|
vad_state = new_vad_state
|
||||||
return vad_state
|
return vad_state
|
||||||
@@ -574,11 +587,19 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
or not self._params.turn_analyzer.speech_triggered
|
or not self._params.turn_analyzer.speech_triggered
|
||||||
)
|
)
|
||||||
if new_vad_state == VADState.SPEAKING:
|
if new_vad_state == VADState.SPEAKING:
|
||||||
await self.push_frame(VADUserStartedSpeakingFrame())
|
start_secs = (
|
||||||
|
self._params.vad_analyzer.params.start_secs
|
||||||
|
if self._params.vad_analyzer
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
await self.push_frame(VADUserStartedSpeakingFrame(start_secs=start_secs))
|
||||||
if can_create_user_frames:
|
if can_create_user_frames:
|
||||||
interruption_state = VADState.SPEAKING
|
interruption_state = VADState.SPEAKING
|
||||||
elif new_vad_state == VADState.QUIET:
|
elif new_vad_state == VADState.QUIET:
|
||||||
await self.push_frame(VADUserStoppedSpeakingFrame())
|
stop_secs = (
|
||||||
|
self._params.vad_analyzer.params.stop_secs if self._params.vad_analyzer else 0.0
|
||||||
|
)
|
||||||
|
await self.push_frame(VADUserStoppedSpeakingFrame(stop_secs=stop_secs))
|
||||||
if can_create_user_frames:
|
if can_create_user_frames:
|
||||||
interruption_state = VADState.QUIET
|
interruption_state = VADState.QUIET
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
from .base_user_turn_stop_strategy import BaseUserTurnStopStrategy, UserTurnStoppedParams
|
from .base_user_turn_stop_strategy import BaseUserTurnStopStrategy, UserTurnStoppedParams
|
||||||
from .external_user_turn_stop_strategy import ExternalUserTurnStopStrategy
|
from .external_user_turn_stop_strategy import ExternalUserTurnStopStrategy
|
||||||
from .transcription_user_turn_stop_strategy import TranscriptionUserTurnStopStrategy
|
from .speech_timeout_user_turn_stop_strategy import SpeechTimeoutUserTurnStopStrategy
|
||||||
from .turn_analyzer_user_turn_stop_strategy import TurnAnalyzerUserTurnStopStrategy
|
from .turn_analyzer_user_turn_stop_strategy import TurnAnalyzerUserTurnStopStrategy
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseUserTurnStopStrategy",
|
"BaseUserTurnStopStrategy",
|
||||||
"ExternalUserTurnStopStrategy",
|
"ExternalUserTurnStopStrategy",
|
||||||
|
"SpeechTimeoutUserTurnStopStrategy",
|
||||||
"UserTurnStoppedParams",
|
"UserTurnStoppedParams",
|
||||||
"TranscriptionUserTurnStopStrategy",
|
|
||||||
"TurnAnalyzerUserTurnStopStrategy",
|
"TurnAnalyzerUserTurnStopStrategy",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024-2026, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Speech timeout-based user turn stop strategy."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
Frame,
|
||||||
|
STTMetadataFrame,
|
||||||
|
TranscriptionFrame,
|
||||||
|
VADUserStartedSpeakingFrame,
|
||||||
|
VADUserStoppedSpeakingFrame,
|
||||||
|
)
|
||||||
|
from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy
|
||||||
|
from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechTimeoutUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
||||||
|
"""User turn stop strategy that uses a configurable timeout to determine if the user is done speaking.
|
||||||
|
|
||||||
|
After the user stops speaking (detected by VAD), this strategy waits for a
|
||||||
|
configurable timeout before triggering the end of the user's turn. The
|
||||||
|
timeout accounts for two factors:
|
||||||
|
|
||||||
|
- user_speech_timeout: Time to wait for the user to potentially say more
|
||||||
|
after they pause.
|
||||||
|
- stt_timeout: The P99 time for the STT service to return a transcription
|
||||||
|
after the user stops speaking, adjusted by the VAD stop_secs.
|
||||||
|
|
||||||
|
For services that support finalization (TranscriptionFrame.finalized=True),
|
||||||
|
the turn can be triggered immediately once the finalized transcript is
|
||||||
|
received and the user resume speaking timeout has elapsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, user_speech_timeout: float = 0.6, **kwargs):
|
||||||
|
"""Initialize the speech timeout-based user turn stop strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_speech_timeout: Time to wait for the user to potentially
|
||||||
|
say more after they pause speaking. Defaults to 0.6 seconds.
|
||||||
|
**kwargs: Additional keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._user_speech_timeout = user_speech_timeout
|
||||||
|
self._stt_timeout: float = 0.0 # STT P99 latency from STTMetadataFrame
|
||||||
|
self._stop_secs: float = 0.0 # VAD stop_secs from VADUserStoppedSpeakingFrame
|
||||||
|
|
||||||
|
self._text = ""
|
||||||
|
self._vad_user_speaking = False
|
||||||
|
self._transcript_finalized = False
|
||||||
|
self._vad_stopped_time: Optional[float] = None
|
||||||
|
self._timeout_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
"""Reset the strategy to its initial state."""
|
||||||
|
await super().reset()
|
||||||
|
self._text = ""
|
||||||
|
self._vad_user_speaking = False
|
||||||
|
self._transcript_finalized = False
|
||||||
|
self._vad_stopped_time = None
|
||||||
|
|
||||||
|
async def setup(self, task_manager: BaseTaskManager):
|
||||||
|
"""Initialize the strategy with the given task manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_manager: The task manager to be associated with this instance.
|
||||||
|
"""
|
||||||
|
await super().setup(task_manager)
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
"""Cleanup the strategy."""
|
||||||
|
await super().cleanup()
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame):
|
||||||
|
"""Process an incoming frame to update strategy state.
|
||||||
|
|
||||||
|
Updates internal transcription text and VAD state. The user end turn
|
||||||
|
will be triggered when appropriate based on the collected frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The frame to be analyzed.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(frame, STTMetadataFrame):
|
||||||
|
self._stt_timeout = frame.ttfs_p99_latency
|
||||||
|
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||||
|
await self._handle_vad_user_started_speaking(frame)
|
||||||
|
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||||
|
await self._handle_vad_user_stopped_speaking(frame)
|
||||||
|
elif isinstance(frame, TranscriptionFrame):
|
||||||
|
await self._handle_transcription(frame)
|
||||||
|
|
||||||
|
async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame):
|
||||||
|
"""Handle when the VAD indicates the user is speaking."""
|
||||||
|
self._vad_user_speaking = True
|
||||||
|
self._transcript_finalized = False
|
||||||
|
self._vad_stopped_time = None
|
||||||
|
# Cancel any pending timeout
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame):
|
||||||
|
"""Handle when the VAD indicates the user has stopped speaking."""
|
||||||
|
self._vad_user_speaking = False
|
||||||
|
self._stop_secs = frame.stop_secs
|
||||||
|
self._vad_stopped_time = frame.timestamp
|
||||||
|
|
||||||
|
# Start the timeout task
|
||||||
|
timeout = self._calculate_timeout()
|
||||||
|
self._timeout_task = self.task_manager.create_task(
|
||||||
|
self._timeout_handler(timeout), f"{self}::_timeout_handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_transcription(self, frame: TranscriptionFrame):
|
||||||
|
"""Handle user transcription."""
|
||||||
|
self._text += frame.text
|
||||||
|
if frame.finalized:
|
||||||
|
self._transcript_finalized = True
|
||||||
|
# For finalized transcripts, check if we can trigger early
|
||||||
|
await self._maybe_trigger_user_turn_stopped()
|
||||||
|
|
||||||
|
# Fallback: handle transcripts when no VAD stop was received.
|
||||||
|
# This handles edge cases where transcripts arrive without VAD firing.
|
||||||
|
# _vad_stopped_time is None means VAD stopped hasn't been received yet.
|
||||||
|
# In fallback mode, reset timeout on each transcript to wait for inactivity.
|
||||||
|
if not self._vad_user_speaking and self._vad_stopped_time is None:
|
||||||
|
# Cancel existing fallback timeout if any
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
timeout = self._calculate_timeout()
|
||||||
|
self._timeout_task = self.task_manager.create_task(
|
||||||
|
self._timeout_handler(timeout), f"{self}::_timeout_handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_timeout(self) -> float:
|
||||||
|
"""Calculate the timeout value based on current state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The timeout in seconds to wait after VAD stopped speaking.
|
||||||
|
"""
|
||||||
|
# Adjust STT timeout by VAD stop_secs since that time has already elapsed
|
||||||
|
effective_stt_wait = max(0, self._stt_timeout - self._stop_secs)
|
||||||
|
|
||||||
|
# If transcript is already finalized, we don't need to wait for STT
|
||||||
|
if self._transcript_finalized:
|
||||||
|
return self._user_speech_timeout
|
||||||
|
|
||||||
|
return max(effective_stt_wait, self._user_speech_timeout)
|
||||||
|
|
||||||
|
async def _timeout_handler(self, timeout: float):
|
||||||
|
"""Wait for the timeout then trigger user turn stopped if conditions met.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: The timeout in seconds to wait.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
|
await self._maybe_trigger_user_turn_stopped()
|
||||||
|
|
||||||
|
async def _maybe_trigger_user_turn_stopped(self):
|
||||||
|
"""Trigger user turn stopped if conditions are met.
|
||||||
|
|
||||||
|
Conditions:
|
||||||
|
- User is not currently speaking
|
||||||
|
- We have transcription text
|
||||||
|
- Either the timeout has elapsed OR we have a finalized transcript
|
||||||
|
and user_speech_timeout has elapsed
|
||||||
|
"""
|
||||||
|
if self._vad_user_speaking or not self._text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# For finalized transcripts, check if user_speech_timeout has elapsed.
|
||||||
|
# If elapsed, trigger user turn stopped immediately. Else, wait for user resume
|
||||||
|
# speaking timeout.
|
||||||
|
if self._transcript_finalized and self._vad_stopped_time is not None:
|
||||||
|
elapsed = time.time() - self._vad_stopped_time
|
||||||
|
if elapsed >= self._user_speech_timeout:
|
||||||
|
# Cancel any remaining timeout since we're triggering now
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
self._timeout_task = None
|
||||||
|
await self.trigger_user_turn_stopped()
|
||||||
|
return
|
||||||
|
|
||||||
|
# For non-finalized, only trigger if timeout task has completed
|
||||||
|
if self._timeout_task is None:
|
||||||
|
await self.trigger_user_turn_stopped()
|
||||||
@@ -4,124 +4,28 @@
|
|||||||
# SPDX-License-Identifier: BSD 2-Clause License
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
#
|
#
|
||||||
|
|
||||||
"""Transcription time-based user turn stop strategy."""
|
"""Transcription-based user turn stop strategy (deprecated).
|
||||||
|
|
||||||
import asyncio
|
.. deprecated:: 0.0.102
|
||||||
from typing import Optional
|
This module is deprecated. Please use
|
||||||
|
``pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy.SpeechTimeoutUserTurnStopStrategy``
|
||||||
|
instead.
|
||||||
|
"""
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
import warnings
|
||||||
Frame,
|
|
||||||
InterimTranscriptionFrame,
|
from pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy import (
|
||||||
TranscriptionFrame,
|
SpeechTimeoutUserTurnStopStrategy,
|
||||||
VADUserStartedSpeakingFrame,
|
|
||||||
VADUserStoppedSpeakingFrame,
|
|
||||||
)
|
)
|
||||||
from pipecat.turns.user_stop.base_user_turn_stop_strategy import BaseUserTurnStopStrategy
|
|
||||||
from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
warnings.warn(
|
||||||
|
"TranscriptionUserTurnStopStrategy is deprecated. "
|
||||||
|
"Please use SpeechTimeoutUserTurnStopStrategy from "
|
||||||
|
"pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
class TranscriptionUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
TranscriptionUserTurnStopStrategy = SpeechTimeoutUserTurnStopStrategy
|
||||||
"""User turn stop strategy based on transcriptions.
|
|
||||||
|
|
||||||
This strategy assumes the user stops speaking once a transcription has been
|
|
||||||
received. It handles multiple or delayed transcription frames gracefully.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *, timeout: float = 0.5, **kwargs):
|
|
||||||
"""Initialize the transcription-based user turn stop strategy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeout: A short delay used internally to handle consecutive or
|
|
||||||
slightly delayed transcriptions.
|
|
||||||
**kwargs: Additional keyword arguments.
|
|
||||||
"""
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._timeout = timeout
|
|
||||||
self._text = ""
|
|
||||||
self._vad_user_speaking = False
|
|
||||||
self._seen_interim_results = False
|
|
||||||
self._event = asyncio.Event()
|
|
||||||
self._task: Optional[asyncio.Task] = None
|
|
||||||
|
|
||||||
async def reset(self):
|
|
||||||
"""Reset the strategy to its initial state."""
|
|
||||||
await super().reset()
|
|
||||||
self._text = ""
|
|
||||||
self._vad_user_speaking = False
|
|
||||||
self._seen_interim_results = False
|
|
||||||
self._event.clear()
|
|
||||||
|
|
||||||
async def setup(self, task_manager: BaseTaskManager):
|
|
||||||
"""Initialize the strategy with the given task manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_manager: The task manager to be associated with this instance.
|
|
||||||
"""
|
|
||||||
await super().setup(task_manager)
|
|
||||||
self._task = task_manager.create_task(self._task_handler(), f"{self}::_task_handler")
|
|
||||||
|
|
||||||
async def cleanup(self):
|
|
||||||
"""Cleanup the strategy."""
|
|
||||||
await super().cleanup()
|
|
||||||
if self._task:
|
|
||||||
await self.task_manager.cancel_task(self._task)
|
|
||||||
self._task = None
|
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame):
|
|
||||||
"""Process an incoming frame to update strategy state.
|
|
||||||
|
|
||||||
Updates internal transcription text and VAD state. The user end turn
|
|
||||||
will be triggered when appropriate based on the collected frames.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
frame: The frame to be analyzed.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if isinstance(frame, VADUserStartedSpeakingFrame):
|
|
||||||
await self._handle_vad_user_started_speaking(frame)
|
|
||||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
|
||||||
await self._handle_vad_user_stopped_speaking(frame)
|
|
||||||
elif isinstance(frame, InterimTranscriptionFrame):
|
|
||||||
await self._handle_interim_transcription(frame)
|
|
||||||
elif isinstance(frame, TranscriptionFrame):
|
|
||||||
await self._handle_transcription(frame)
|
|
||||||
|
|
||||||
async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame):
|
|
||||||
"""Handle when the VAD indicates the user is speaking."""
|
|
||||||
self._vad_user_speaking = True
|
|
||||||
|
|
||||||
async def _handle_vad_user_stopped_speaking(self, _: VADUserStoppedSpeakingFrame):
|
|
||||||
"""Handle when the VAD indicates the user has stopped speaking."""
|
|
||||||
self._vad_user_speaking = False
|
|
||||||
await self._maybe_trigger_user_turn_stopped()
|
|
||||||
|
|
||||||
async def _handle_interim_transcription(self, frame: InterimTranscriptionFrame):
|
|
||||||
self._seen_interim_results = True
|
|
||||||
|
|
||||||
async def _handle_transcription(self, frame: TranscriptionFrame):
|
|
||||||
"""Handle user transcription."""
|
|
||||||
self._text += frame.text
|
|
||||||
# We just got a final result, so let's reset interim results.
|
|
||||||
self._seen_interim_results = False
|
|
||||||
# Reset aggregation timer.
|
|
||||||
self._event.set()
|
|
||||||
|
|
||||||
async def _task_handler(self):
|
|
||||||
"""Asynchronously monitor transcriptions and trigger user end turn when ready.
|
|
||||||
|
|
||||||
If transcription text exists and the user is not currently speaking,
|
|
||||||
triggers the user end turn. Handles multiple or delayed transcriptions
|
|
||||||
gracefully.
|
|
||||||
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._event.wait(), timeout=self._timeout)
|
|
||||||
self._event.clear()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
await self._maybe_trigger_user_turn_stopped()
|
|
||||||
|
|
||||||
async def _maybe_trigger_user_turn_stopped(self):
|
|
||||||
if not self._vad_user_speaking and not self._seen_interim_results and self._text:
|
|
||||||
await self.trigger_user_turn_stopped()
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnSta
|
|||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
Frame,
|
Frame,
|
||||||
InputAudioRawFrame,
|
InputAudioRawFrame,
|
||||||
InterimTranscriptionFrame,
|
|
||||||
MetricsFrame,
|
MetricsFrame,
|
||||||
SpeechControlParamsFrame,
|
SpeechControlParamsFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
|
STTMetadataFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
VADUserStartedSpeakingFrame,
|
VADUserStartedSpeakingFrame,
|
||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
@@ -27,30 +27,38 @@ from pipecat.utils.asyncio.task_manager import BaseTaskManager
|
|||||||
|
|
||||||
|
|
||||||
class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
||||||
"""User turn stop strategy using a turn detection model to detect end of user turn.
|
"""User turn stop strategy that uses a turn detection model to determine if the user is done speaking.
|
||||||
|
|
||||||
This strategy uses the turn detection models to determine when the user has
|
This strategy feeds audio, VAD, and transcription frames to a turn
|
||||||
finished speaking, combining audio, VAD, and transcription frames. Once the
|
detection model (``BaseTurnAnalyzer``) that predicts when the user has
|
||||||
turn is considered complete, the user end of turn is triggered.
|
finished their turn. Once the model indicates the turn is complete, the
|
||||||
|
strategy waits for a final transcription before triggering the end of
|
||||||
|
the user's turn.
|
||||||
|
|
||||||
|
For services that support finalization (TranscriptionFrame.finalized=True),
|
||||||
|
the turn can be triggered immediately once the finalized transcript is
|
||||||
|
received. Otherwise, an STT timeout (adjusted by VAD stop_secs) is used
|
||||||
|
as a fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, turn_analyzer: BaseTurnAnalyzer, timeout: float = 0.5, **kwargs):
|
def __init__(self, *, turn_analyzer: BaseTurnAnalyzer, **kwargs):
|
||||||
"""Initialize the user turn stop strategy.
|
"""Initialize the user turn stop strategy.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
turn_analyzer: The turn detection analyzer instance to detect end of user turn.
|
turn_analyzer: The turn detection analyzer instance to detect end of user turn.
|
||||||
timeout: Short delay used internally to handle frame timing and event triggering.
|
|
||||||
**kwargs: Additional keyword arguments.
|
**kwargs: Additional keyword arguments.
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._turn_analyzer = turn_analyzer
|
self._turn_analyzer = turn_analyzer
|
||||||
self._timeout = timeout
|
self._stt_timeout: float = 0.0 # STT P99 latency from STTMetadataFrame
|
||||||
|
self._stop_secs: float = 0.0 # VAD stop_secs from VADUserStoppedSpeakingFrame
|
||||||
|
|
||||||
self._text = ""
|
self._text = ""
|
||||||
self._turn_complete = False
|
self._turn_complete = False
|
||||||
self._vad_user_speaking = False
|
self._vad_user_speaking = False
|
||||||
self._event = asyncio.Event()
|
self._vad_stopped_time: Optional[float] = None # Track when VAD stopped was received
|
||||||
self._task: Optional[asyncio.Task] = None
|
self._transcript_finalized = False
|
||||||
|
self._timeout_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def reset(self):
|
async def reset(self):
|
||||||
"""Reset the strategy to its initial state."""
|
"""Reset the strategy to its initial state."""
|
||||||
@@ -58,7 +66,8 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
self._text = ""
|
self._text = ""
|
||||||
self._turn_complete = False
|
self._turn_complete = False
|
||||||
self._vad_user_speaking = False
|
self._vad_user_speaking = False
|
||||||
self._event.clear()
|
self._vad_stopped_time = None
|
||||||
|
self._transcript_finalized = False
|
||||||
|
|
||||||
async def setup(self, task_manager: BaseTaskManager):
|
async def setup(self, task_manager: BaseTaskManager):
|
||||||
"""Initialize the strategy with the given task manager.
|
"""Initialize the strategy with the given task manager.
|
||||||
@@ -67,15 +76,14 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
task_manager: The task manager to be associated with this instance.
|
task_manager: The task manager to be associated with this instance.
|
||||||
"""
|
"""
|
||||||
await super().setup(task_manager)
|
await super().setup(task_manager)
|
||||||
self._task = task_manager.create_task(self._task_handler(), f"{self}::_task_handler")
|
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self):
|
||||||
"""Cleanup the strategy."""
|
"""Cleanup the strategy."""
|
||||||
await super().cleanup()
|
await super().cleanup()
|
||||||
await self._turn_analyzer.cleanup()
|
await self._turn_analyzer.cleanup()
|
||||||
if self._task:
|
if self._timeout_task:
|
||||||
await self.task_manager.cancel_task(self._task)
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
self._task = None
|
self._timeout_task = None
|
||||||
|
|
||||||
async def process_frame(self, frame: Frame):
|
async def process_frame(self, frame: Frame):
|
||||||
"""Process an incoming frame to update the turn analyzer and strategy state.
|
"""Process an incoming frame to update the turn analyzer and strategy state.
|
||||||
@@ -87,8 +95,8 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
|
|
||||||
if isinstance(frame, StartFrame):
|
if isinstance(frame, StartFrame):
|
||||||
await self._start(frame)
|
await self._start(frame)
|
||||||
elif isinstance(frame, SpeechControlParamsFrame):
|
elif isinstance(frame, STTMetadataFrame):
|
||||||
await self._handle_speech_control_params(frame)
|
self._stt_timeout = frame.ttfs_p99_latency
|
||||||
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
elif isinstance(frame, VADUserStartedSpeakingFrame):
|
||||||
await self._handle_vad_user_started_speaking(frame)
|
await self._handle_vad_user_started_speaking(frame)
|
||||||
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
elif isinstance(frame, VADUserStoppedSpeakingFrame):
|
||||||
@@ -97,25 +105,12 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
await self._handle_input_audio(frame)
|
await self._handle_input_audio(frame)
|
||||||
elif isinstance(frame, TranscriptionFrame):
|
elif isinstance(frame, TranscriptionFrame):
|
||||||
await self._handle_transcription(frame)
|
await self._handle_transcription(frame)
|
||||||
elif isinstance(frame, InterimTranscriptionFrame):
|
|
||||||
await self._handle_interim_transcription(frame)
|
|
||||||
|
|
||||||
async def _start(self, frame: StartFrame):
|
async def _start(self, frame: StartFrame):
|
||||||
"""Process the start frame to configure the turn analyzer."""
|
"""Process the start frame to configure the turn analyzer."""
|
||||||
self._turn_analyzer.set_sample_rate(frame.audio_in_sample_rate)
|
self._turn_analyzer.set_sample_rate(frame.audio_in_sample_rate)
|
||||||
await self.broadcast_frame(SpeechControlParamsFrame, turn_params=self._turn_analyzer.params)
|
await self.broadcast_frame(SpeechControlParamsFrame, turn_params=self._turn_analyzer.params)
|
||||||
|
|
||||||
async def _handle_speech_control_params(self, frame: SpeechControlParamsFrame):
|
|
||||||
"""Sync Smart Turn pre-speech buffering with VAD start delay.
|
|
||||||
|
|
||||||
`VADUserStartedSpeakingFrame` is emitted only once VAD has confirmed speech
|
|
||||||
(after `vad_params.start_secs`). Smart Turn should still include the initial
|
|
||||||
audio collected during that confirmation window, so we let the analyzer know
|
|
||||||
when this value has changed.
|
|
||||||
"""
|
|
||||||
if frame.vad_params:
|
|
||||||
self._turn_analyzer.update_vad_start_secs(frame.vad_params.start_secs)
|
|
||||||
|
|
||||||
async def _handle_input_audio(self, frame: InputAudioRawFrame):
|
async def _handle_input_audio(self, frame: InputAudioRawFrame):
|
||||||
"""Handle input audio to check if the turn is completed."""
|
"""Handle input audio to check if the turn is completed."""
|
||||||
state = self._turn_analyzer.append_audio(frame.audio, self._vad_user_speaking)
|
state = self._turn_analyzer.append_audio(frame.audio, self._vad_user_speaking)
|
||||||
@@ -127,14 +122,24 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
self._turn_complete = True
|
self._turn_complete = True
|
||||||
await self._maybe_trigger_user_turn_stopped()
|
await self._maybe_trigger_user_turn_stopped()
|
||||||
|
|
||||||
async def _handle_vad_user_started_speaking(self, _: VADUserStartedSpeakingFrame):
|
async def _handle_vad_user_started_speaking(self, frame: VADUserStartedSpeakingFrame):
|
||||||
"""Handle when the VAD indicates the user is speaking."""
|
"""Handle when the VAD indicates the user is speaking."""
|
||||||
|
# Sync Smart Turn pre-speech buffering with VAD start delay
|
||||||
|
self._turn_analyzer.update_vad_start_secs(frame.start_secs)
|
||||||
self._turn_complete = False
|
self._turn_complete = False
|
||||||
self._vad_user_speaking = True
|
self._vad_user_speaking = True
|
||||||
|
self._vad_stopped_time = None
|
||||||
|
self._transcript_finalized = False
|
||||||
|
# Cancel any pending timeout
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
self._timeout_task = None
|
||||||
|
|
||||||
async def _handle_vad_user_stopped_speaking(self, _: VADUserStoppedSpeakingFrame):
|
async def _handle_vad_user_stopped_speaking(self, frame: VADUserStoppedSpeakingFrame):
|
||||||
"""Handle when the VAD indicates the user has stopped speaking."""
|
"""Handle when the VAD indicates the user has stopped speaking."""
|
||||||
self._vad_user_speaking = False
|
self._vad_user_speaking = False
|
||||||
|
self._stop_secs = frame.stop_secs
|
||||||
|
self._vad_stopped_time = frame.timestamp
|
||||||
|
|
||||||
state, prediction = await self._turn_analyzer.analyze_end_of_turn()
|
state, prediction = await self._turn_analyzer.analyze_end_of_turn()
|
||||||
await self._handle_prediction_result(prediction)
|
await self._handle_prediction_result(prediction)
|
||||||
@@ -143,41 +148,76 @@ class TurnAnalyzerUserTurnStopStrategy(BaseUserTurnStopStrategy):
|
|||||||
# wait for transcriptions.
|
# wait for transcriptions.
|
||||||
self._turn_complete = state == EndOfTurnState.COMPLETE
|
self._turn_complete = state == EndOfTurnState.COMPLETE
|
||||||
|
|
||||||
# Reset transcription timeout.
|
# Start the STT timeout (adjusted by VAD stop_secs since that time already elapsed)
|
||||||
self._event.set()
|
timeout = max(0, self._stt_timeout - self._stop_secs)
|
||||||
|
self._timeout_task = self.task_manager.create_task(
|
||||||
|
self._timeout_handler(timeout), f"{self}::_timeout_handler"
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_transcription(self, frame: TranscriptionFrame):
|
async def _handle_transcription(self, frame: TranscriptionFrame):
|
||||||
"""Handle user transcription."""
|
"""Handle user transcription."""
|
||||||
# We don't really care about the content.
|
# We don't really care about the content.
|
||||||
self._text = frame.text
|
self._text = frame.text
|
||||||
# Reset transcription timeout.
|
if frame.finalized:
|
||||||
self._event.set()
|
self._transcript_finalized = True
|
||||||
|
# For finalized transcripts, trigger immediately if turn is complete
|
||||||
|
await self._maybe_trigger_user_turn_stopped()
|
||||||
|
|
||||||
async def _handle_interim_transcription(self, frame: InterimTranscriptionFrame):
|
# Fallback: handle transcripts when no VAD stop was received.
|
||||||
"""Handle user interim transcription."""
|
# This handles edge cases where transcripts arrive without VAD firing.
|
||||||
# Reset transcription timeout.
|
# _vad_stopped_time is None means VAD stopped hasn't been received yet.
|
||||||
self._event.set()
|
# In fallback mode, reset timeout on each transcript to wait for inactivity.
|
||||||
|
if not self._vad_user_speaking and self._vad_stopped_time is None:
|
||||||
|
# Cancel existing fallback timeout if any
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
# Without VAD/turn analyzer data, assume turn is complete
|
||||||
|
self._turn_complete = True
|
||||||
|
timeout = max(0, self._stt_timeout - self._stop_secs)
|
||||||
|
self._timeout_task = self.task_manager.create_task(
|
||||||
|
self._timeout_handler(timeout), f"{self}::_timeout_handler"
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_prediction_result(self, result: Optional[MetricsData]):
|
async def _handle_prediction_result(self, result: Optional[MetricsData]):
|
||||||
"""Handle a prediction result event from the turn analyzer."""
|
"""Handle a prediction result event from the turn analyzer."""
|
||||||
if result:
|
if result:
|
||||||
await self.push_frame(MetricsFrame(data=[result]))
|
await self.push_frame(MetricsFrame(data=[result]))
|
||||||
|
|
||||||
async def _task_handler(self):
|
async def _timeout_handler(self, timeout: float):
|
||||||
"""Asynchronously monitor events and trigger user end of turn when appropriate.
|
"""Wait for the timeout then trigger user turn stopped if conditions met.
|
||||||
|
|
||||||
If we have not received a transcription in the specified amount of time
|
|
||||||
(and we initially received one) and the turn analyzer said the turn is
|
|
||||||
done, then the user is done speaking.
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: The timeout in seconds to wait.
|
||||||
"""
|
"""
|
||||||
while True:
|
try:
|
||||||
try:
|
await asyncio.sleep(timeout)
|
||||||
await asyncio.wait_for(self._event.wait(), timeout=self._timeout)
|
except asyncio.CancelledError:
|
||||||
self._event.clear()
|
return
|
||||||
except asyncio.TimeoutError:
|
finally:
|
||||||
await self._maybe_trigger_user_turn_stopped()
|
self._timeout_task = None
|
||||||
|
|
||||||
|
await self._maybe_trigger_user_turn_stopped()
|
||||||
|
|
||||||
async def _maybe_trigger_user_turn_stopped(self):
|
async def _maybe_trigger_user_turn_stopped(self):
|
||||||
if self._text and self._turn_complete:
|
"""Trigger user turn stopped if conditions are met.
|
||||||
|
|
||||||
|
Conditions:
|
||||||
|
- We have transcription text
|
||||||
|
- Turn analyzer indicates turn is complete
|
||||||
|
- Either the timeout has elapsed OR we have a finalized transcript
|
||||||
|
"""
|
||||||
|
if not self._text or not self._turn_complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# For finalized transcripts, trigger immediately
|
||||||
|
if self._transcript_finalized:
|
||||||
|
# Cancel any remaining timeout since we're triggering now
|
||||||
|
if self._timeout_task:
|
||||||
|
await self.task_manager.cancel_task(self._timeout_task)
|
||||||
|
self._timeout_task = None
|
||||||
|
await self.trigger_user_turn_stopped()
|
||||||
|
return
|
||||||
|
|
||||||
|
# For non-finalized, only trigger if timeout task has completed
|
||||||
|
if self._timeout_task is None:
|
||||||
await self.trigger_user_turn_stopped()
|
await self.trigger_user_turn_stopped()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from pipecat.turns.user_start import (
|
|||||||
from pipecat.turns.user_stop import (
|
from pipecat.turns.user_stop import (
|
||||||
BaseUserTurnStopStrategy,
|
BaseUserTurnStopStrategy,
|
||||||
ExternalUserTurnStopStrategy,
|
ExternalUserTurnStopStrategy,
|
||||||
TranscriptionUserTurnStopStrategy,
|
SpeechTimeoutUserTurnStopStrategy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class UserTurnStrategies:
|
|||||||
If no strategies are specified, the following defaults are used:
|
If no strategies are specified, the following defaults are used:
|
||||||
|
|
||||||
start: [VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy]
|
start: [VADUserTurnStartStrategy, TranscriptionUserTurnStartStrategy]
|
||||||
stop: [TranscriptionUserTurnStopStrategy]
|
stop: [SpeechTimeoutUserTurnStopStrategy]
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
start: A list of user turn start strategies used to detect when
|
start: A list of user turn start strategies used to detect when
|
||||||
@@ -46,7 +46,7 @@ class UserTurnStrategies:
|
|||||||
if not self.start:
|
if not self.start:
|
||||||
self.start = [VADUserTurnStartStrategy(), TranscriptionUserTurnStartStrategy()]
|
self.start = [VADUserTurnStartStrategy(), TranscriptionUserTurnStartStrategy()]
|
||||||
if not self.stop:
|
if not self.stop:
|
||||||
self.stop = [TranscriptionUserTurnStopStrategy()]
|
self.stop = [SpeechTimeoutUserTurnStopStrategy()]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from pipecat.processors.aggregators.llm_response_universal import (
|
|||||||
)
|
)
|
||||||
from pipecat.tests.utils import SleepFrame, run_test
|
from pipecat.tests.utils import SleepFrame, run_test
|
||||||
from pipecat.turns.user_mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy
|
from pipecat.turns.user_mute import FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy
|
||||||
from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy
|
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
|
||||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||||
|
|
||||||
USER_TURN_STOP_TIMEOUT = 0.2
|
USER_TURN_STOP_TIMEOUT = 0.2
|
||||||
@@ -149,7 +149,16 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
async def test_default_user_turn_strategies(self):
|
async def test_default_user_turn_strategies(self):
|
||||||
context = LLMContext()
|
context = LLMContext()
|
||||||
user_aggregator = LLMUserAggregator(context)
|
user_aggregator = LLMUserAggregator(
|
||||||
|
context,
|
||||||
|
params=LLMUserAggregatorParams(
|
||||||
|
user_turn_strategies=UserTurnStrategies(
|
||||||
|
stop=[
|
||||||
|
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
should_stop = None
|
should_stop = None
|
||||||
@@ -173,6 +182,8 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
|
|||||||
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
||||||
SleepFrame(),
|
SleepFrame(),
|
||||||
VADUserStoppedSpeakingFrame(),
|
VADUserStoppedSpeakingFrame(),
|
||||||
|
# Wait for user_speech_timeout to elapse
|
||||||
|
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
|
||||||
]
|
]
|
||||||
expected_down_frames = [
|
expected_down_frames = [
|
||||||
VADUserStartedSpeakingFrame,
|
VADUserStartedSpeakingFrame,
|
||||||
@@ -241,7 +252,9 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
|
|||||||
context,
|
context,
|
||||||
params=LLMUserAggregatorParams(
|
params=LLMUserAggregatorParams(
|
||||||
user_turn_strategies=UserTurnStrategies(
|
user_turn_strategies=UserTurnStrategies(
|
||||||
stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)],
|
stop=[
|
||||||
|
SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
|
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
|
||||||
),
|
),
|
||||||
@@ -270,13 +283,13 @@ class TestLLMUserAggregator(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
pipeline = Pipeline([user_aggregator])
|
pipeline = Pipeline([user_aggregator])
|
||||||
|
|
||||||
|
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
|
||||||
frames_to_send = [
|
frames_to_send = [
|
||||||
VADUserStartedSpeakingFrame(),
|
VADUserStartedSpeakingFrame(),
|
||||||
VADUserStoppedSpeakingFrame(),
|
|
||||||
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
|
|
||||||
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
||||||
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
|
VADUserStoppedSpeakingFrame(),
|
||||||
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT),
|
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
|
||||||
|
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
|
||||||
]
|
]
|
||||||
await run_test(
|
await run_test(
|
||||||
pipeline,
|
pipeline,
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ from pipecat.frames.frames import (
|
|||||||
UserStartedSpeakingFrame,
|
UserStartedSpeakingFrame,
|
||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.processors.filters.frame_filter import FrameFilter
|
from pipecat.processors.filters.frame_filter import FrameFilter
|
||||||
from pipecat.processors.filters.function_filter import FunctionFilter
|
from pipecat.processors.filters.function_filter import FunctionFilter
|
||||||
from pipecat.processors.filters.identity_filter import IdentityFilter
|
from pipecat.processors.filters.identity_filter import IdentityFilter
|
||||||
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
|
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
from pipecat.tests.utils import run_test
|
from pipecat.tests.utils import run_test
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +95,98 @@ class TestFunctionFilter(unittest.IsolatedAsyncioTestCase):
|
|||||||
expected_down_frames=expected_down_frames,
|
expected_down_frames=expected_down_frames,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_no_direction_filters_both_directions(self):
|
||||||
|
"""When direction is None, frames in both directions are filtered."""
|
||||||
|
|
||||||
|
class UpstreamPusher(FrameProcessor):
|
||||||
|
"""Pushes a TextFrame upstream when it receives a system frame."""
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
|
async def block_text(frame: Frame):
|
||||||
|
return not isinstance(frame, TextFrame)
|
||||||
|
|
||||||
|
# direction=None: filter applies in both directions. The downstream
|
||||||
|
# TextFrame is blocked and the upstream TextFrame pushed by
|
||||||
|
# UpstreamPusher is also blocked.
|
||||||
|
filter = FunctionFilter(filter=block_text, direction=None)
|
||||||
|
pipeline = Pipeline([filter, UpstreamPusher()])
|
||||||
|
frames_to_send = [
|
||||||
|
TextFrame(text="Hello!"),
|
||||||
|
UserStartedSpeakingFrame(),
|
||||||
|
]
|
||||||
|
expected_down_frames = [UserStartedSpeakingFrame]
|
||||||
|
expected_up_frames = []
|
||||||
|
await run_test(
|
||||||
|
pipeline,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
expected_down_frames=expected_down_frames,
|
||||||
|
expected_up_frames=expected_up_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_downstream_direction_passes_upstream(self):
|
||||||
|
"""When direction is DOWNSTREAM, upstream frames pass through unfiltered."""
|
||||||
|
|
||||||
|
class UpstreamPusher(FrameProcessor):
|
||||||
|
"""Pushes a TextFrame upstream when it receives a system frame."""
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
|
async def block_text(frame: Frame):
|
||||||
|
return not isinstance(frame, TextFrame)
|
||||||
|
|
||||||
|
# direction=DOWNSTREAM: filter only applies downstream, so the
|
||||||
|
# upstream TextFrame pushed by UpstreamPusher passes through.
|
||||||
|
filter = FunctionFilter(filter=block_text)
|
||||||
|
pipeline = Pipeline([filter, UpstreamPusher()])
|
||||||
|
frames_to_send = [UserStartedSpeakingFrame()]
|
||||||
|
expected_down_frames = [UserStartedSpeakingFrame]
|
||||||
|
expected_up_frames = [TextFrame]
|
||||||
|
await run_test(
|
||||||
|
pipeline,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
expected_down_frames=expected_down_frames,
|
||||||
|
expected_up_frames=expected_up_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_upstream_direction_passes_downstream(self):
|
||||||
|
"""When direction is UPSTREAM, downstream frames pass through unfiltered."""
|
||||||
|
|
||||||
|
class UpstreamPusher(FrameProcessor):
|
||||||
|
"""Pushes a TextFrame upstream when it receives a system frame."""
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
if isinstance(frame, UserStartedSpeakingFrame):
|
||||||
|
await self.push_frame(TextFrame(text="upstream"), FrameDirection.UPSTREAM)
|
||||||
|
|
||||||
|
async def block_text(frame: Frame):
|
||||||
|
return not isinstance(frame, TextFrame)
|
||||||
|
|
||||||
|
# direction=UPSTREAM: filter only applies upstream, so the
|
||||||
|
# downstream TextFrame passes through but the upstream TextFrame
|
||||||
|
# pushed by UpstreamPusher is blocked.
|
||||||
|
filter = FunctionFilter(filter=block_text, direction=FrameDirection.UPSTREAM)
|
||||||
|
pipeline = Pipeline([filter, UpstreamPusher()])
|
||||||
|
frames_to_send = [TextFrame(text="Hello!"), UserStartedSpeakingFrame()]
|
||||||
|
expected_down_frames = [UserStartedSpeakingFrame, TextFrame]
|
||||||
|
expected_up_frames = []
|
||||||
|
await run_test(
|
||||||
|
pipeline,
|
||||||
|
frames_to_send=frames_to_send,
|
||||||
|
expected_down_frames=expected_down_frames,
|
||||||
|
expected_up_frames=expected_up_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
|
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_no_wake_word(self):
|
async def test_no_wake_word(self):
|
||||||
|
|||||||
@@ -6,12 +6,16 @@
|
|||||||
|
|
||||||
"""Unit tests for ServiceSwitcher and related components."""
|
"""Unit tests for ServiceSwitcher and related components."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
Frame,
|
Frame,
|
||||||
ManuallySwitchServiceFrame,
|
ManuallySwitchServiceFrame,
|
||||||
|
RequestMetadataFrame,
|
||||||
|
ServiceMetadataFrame,
|
||||||
|
StartFrame,
|
||||||
SystemFrame,
|
SystemFrame,
|
||||||
TextFrame,
|
TextFrame,
|
||||||
)
|
)
|
||||||
@@ -54,6 +58,47 @@ class MockFrameProcessor(FrameProcessor):
|
|||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MockMetadataFrame(ServiceMetadataFrame):
|
||||||
|
"""A mock metadata frame for testing ServiceMetadataFrame handling."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MockMetadataService(FrameProcessor):
|
||||||
|
"""A mock service that emits ServiceMetadataFrame like STT services.
|
||||||
|
|
||||||
|
Pushes MockMetadataFrame on StartFrame and RequestMetadataFrame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, test_name: str, **kwargs):
|
||||||
|
super().__init__(name=test_name, **kwargs)
|
||||||
|
self.test_name = test_name
|
||||||
|
self.processed_frames = []
|
||||||
|
self.metadata_push_count = 0
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
self.processed_frames.append(frame)
|
||||||
|
|
||||||
|
if isinstance(frame, StartFrame):
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
await self._push_metadata()
|
||||||
|
elif isinstance(frame, RequestMetadataFrame):
|
||||||
|
# Don't push RequestMetadataFrame downstream (it's internal)
|
||||||
|
await self._push_metadata()
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
async def _push_metadata(self):
|
||||||
|
self.metadata_push_count += 1
|
||||||
|
await self.push_frame(MockMetadataFrame(service_name=self.test_name))
|
||||||
|
|
||||||
|
def reset_counters(self):
|
||||||
|
self.processed_frames = []
|
||||||
|
self.metadata_push_count = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummySystemFrame(SystemFrame):
|
class DummySystemFrame(SystemFrame):
|
||||||
"""A dummy system frame for testing purposes."""
|
"""A dummy system frame for testing purposes."""
|
||||||
@@ -78,14 +123,7 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(strategy.services, self.services)
|
self.assertEqual(strategy.services, self.services)
|
||||||
self.assertEqual(strategy.active_service, self.service1) # First service should be active
|
self.assertEqual(strategy.active_service, self.service1) # First service should be active
|
||||||
|
|
||||||
def test_init_with_empty_services(self):
|
async def test_handle_manually_switch_service_frame(self):
|
||||||
"""Test initialization with an empty list of services."""
|
|
||||||
strategy = ServiceSwitcherStrategyManual([])
|
|
||||||
|
|
||||||
self.assertEqual(strategy.services, [])
|
|
||||||
self.assertIsNone(strategy.active_service)
|
|
||||||
|
|
||||||
def test_handle_manually_switch_service_frame(self):
|
|
||||||
"""Test manual service switching with ManuallySwitchServiceFrame."""
|
"""Test manual service switching with ManuallySwitchServiceFrame."""
|
||||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||||
|
|
||||||
@@ -95,7 +133,7 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# Switch to service2
|
# Switch to service2
|
||||||
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
||||||
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||||
|
|
||||||
self.assertNotEqual(strategy.active_service, self.service1)
|
self.assertNotEqual(strategy.active_service, self.service1)
|
||||||
self.assertEqual(strategy.active_service, self.service2)
|
self.assertEqual(strategy.active_service, self.service2)
|
||||||
@@ -103,21 +141,66 @@ class TestServiceSwitcherStrategyManual(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# Switch to service3
|
# Switch to service3
|
||||||
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
|
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
|
||||||
strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||||
|
|
||||||
self.assertNotEqual(strategy.active_service, self.service1)
|
self.assertNotEqual(strategy.active_service, self.service1)
|
||||||
self.assertNotEqual(strategy.active_service, self.service2)
|
self.assertNotEqual(strategy.active_service, self.service2)
|
||||||
self.assertEqual(strategy.active_service, self.service3)
|
self.assertEqual(strategy.active_service, self.service3)
|
||||||
|
|
||||||
def test_handle_frame_unsupported_frame_type(self):
|
async def test_on_service_switched_event(self):
|
||||||
|
"""Test that on_service_switched event fires with correct arguments."""
|
||||||
|
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||||
|
|
||||||
|
switched_events = []
|
||||||
|
|
||||||
|
@strategy.event_handler("on_service_switched")
|
||||||
|
async def on_service_switched(strategy, service):
|
||||||
|
switched_events.append((strategy, service))
|
||||||
|
|
||||||
|
# Switch to service2
|
||||||
|
switch_frame = ManuallySwitchServiceFrame(service=self.service2)
|
||||||
|
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||||
|
await asyncio.sleep(0) # Let async event task run
|
||||||
|
|
||||||
|
self.assertEqual(len(switched_events), 1)
|
||||||
|
self.assertIsInstance(switched_events[0][0], ServiceSwitcherStrategyManual)
|
||||||
|
self.assertEqual(switched_events[0][1], self.service2)
|
||||||
|
|
||||||
|
# Switch to service3
|
||||||
|
switch_frame = ManuallySwitchServiceFrame(service=self.service3)
|
||||||
|
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
self.assertEqual(len(switched_events), 2)
|
||||||
|
self.assertEqual(switched_events[1][1], self.service3)
|
||||||
|
|
||||||
|
async def test_on_service_switched_event_not_fired_for_unknown_service(self):
|
||||||
|
"""Test that on_service_switched event does not fire for services not in the list."""
|
||||||
|
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||||
|
|
||||||
|
switched_events = []
|
||||||
|
|
||||||
|
@strategy.event_handler("on_service_switched")
|
||||||
|
async def on_service_switched(strategy, service):
|
||||||
|
switched_events.append(service)
|
||||||
|
|
||||||
|
# Try switching to a service not in the list
|
||||||
|
unknown_service = MockFrameProcessor("unknown")
|
||||||
|
switch_frame = ManuallySwitchServiceFrame(service=unknown_service)
|
||||||
|
await strategy.handle_frame(switch_frame, FrameDirection.DOWNSTREAM)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
self.assertEqual(len(switched_events), 0)
|
||||||
|
self.assertEqual(strategy.active_service, self.service1) # Unchanged
|
||||||
|
|
||||||
|
async def test_handle_frame_unsupported_frame_type(self):
|
||||||
"""Test that unsupported frame types raise an error."""
|
"""Test that unsupported frame types raise an error."""
|
||||||
strategy = ServiceSwitcherStrategyManual(self.services)
|
strategy = ServiceSwitcherStrategyManual(self.services)
|
||||||
unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame
|
unsupported_frame = TextFrame(text="test") # Not a ServiceSwitcherFrame
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as context:
|
result = await strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
|
||||||
strategy.handle_frame(unsupported_frame, FrameDirection.DOWNSTREAM)
|
|
||||||
|
|
||||||
self.assertIn("Unsupported frame type", str(context.exception))
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
||||||
@@ -223,7 +306,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
|||||||
ManuallySwitchServiceFrame(service=self.service2),
|
ManuallySwitchServiceFrame(service=self.service2),
|
||||||
TextFrame("Hello 2"),
|
TextFrame("Hello 2"),
|
||||||
],
|
],
|
||||||
expected_down_frames=[TextFrame, ManuallySwitchServiceFrame, TextFrame],
|
expected_down_frames=[TextFrame, TextFrame],
|
||||||
expected_up_frames=[], # Expect no error frames
|
expected_up_frames=[], # Expect no error frames
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -289,9 +372,7 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
|||||||
],
|
],
|
||||||
expected_down_frames=[
|
expected_down_frames=[
|
||||||
TextFrame,
|
TextFrame,
|
||||||
ManuallySwitchServiceFrame,
|
|
||||||
TextFrame,
|
TextFrame,
|
||||||
ManuallySwitchServiceFrame,
|
|
||||||
TextFrame,
|
TextFrame,
|
||||||
],
|
],
|
||||||
expected_up_frames=[], # Expect no error frames
|
expected_up_frames=[], # Expect no error frames
|
||||||
@@ -336,5 +417,83 @@ class TestServiceSwitcher(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(switcher2_service2_texts[0].text, "After switching second switcher")
|
self.assertEqual(switcher2_service2_texts[0].text, "After switching second switcher")
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceSwitcherMetadata(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""Test cases for ServiceMetadataFrame handling in ServiceSwitcher."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test fixtures with mock metadata services."""
|
||||||
|
self.service1 = MockMetadataService("service1")
|
||||||
|
self.service2 = MockMetadataService("service2")
|
||||||
|
self.services = [self.service1, self.service2]
|
||||||
|
|
||||||
|
async def test_only_active_service_metadata_at_startup(self):
|
||||||
|
"""Test that only the active service's metadata leaves the ServiceSwitcher at startup."""
|
||||||
|
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||||
|
|
||||||
|
# Run the pipeline (StartFrame triggers metadata emission)
|
||||||
|
output_frames = []
|
||||||
|
|
||||||
|
async def capture_frame(frame: Frame):
|
||||||
|
output_frames.append(frame)
|
||||||
|
|
||||||
|
await run_test(
|
||||||
|
switcher,
|
||||||
|
frames_to_send=[TextFrame(text="test")],
|
||||||
|
expected_down_frames=[MockMetadataFrame, TextFrame],
|
||||||
|
expected_up_frames=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both services push metadata internally on StartFrame, but only the
|
||||||
|
# active service's metadata passes through the filter
|
||||||
|
self.assertEqual(self.service1.metadata_push_count, 1) # StartFrame (passes filter)
|
||||||
|
self.assertEqual(self.service2.metadata_push_count, 1) # StartFrame (blocked by filter)
|
||||||
|
|
||||||
|
async def test_metadata_emitted_on_service_switch(self):
|
||||||
|
"""Test that switching services triggers metadata emission from the new active service."""
|
||||||
|
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||||
|
|
||||||
|
# Reset counters after startup
|
||||||
|
self.service1.reset_counters()
|
||||||
|
self.service2.reset_counters()
|
||||||
|
|
||||||
|
await run_test(
|
||||||
|
switcher,
|
||||||
|
frames_to_send=[
|
||||||
|
TextFrame(text="before switch"),
|
||||||
|
ManuallySwitchServiceFrame(service=self.service2),
|
||||||
|
TextFrame(text="after switch"),
|
||||||
|
],
|
||||||
|
expected_down_frames=[
|
||||||
|
MockMetadataFrame, # From startup (service1)
|
||||||
|
TextFrame,
|
||||||
|
MockMetadataFrame, # From service2 after switch
|
||||||
|
TextFrame,
|
||||||
|
],
|
||||||
|
expected_up_frames=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# service2 should have received RequestMetadataFrame after becoming active
|
||||||
|
request_frames = [
|
||||||
|
f for f in self.service2.processed_frames if isinstance(f, RequestMetadataFrame)
|
||||||
|
]
|
||||||
|
self.assertEqual(len(request_frames), 1)
|
||||||
|
|
||||||
|
async def test_inactive_service_metadata_blocked(self):
|
||||||
|
"""Test that metadata from inactive services is blocked."""
|
||||||
|
switcher = ServiceSwitcher(self.services, ServiceSwitcherStrategyManual)
|
||||||
|
|
||||||
|
# Run and collect output frames
|
||||||
|
await run_test(
|
||||||
|
switcher,
|
||||||
|
frames_to_send=[TextFrame(text="test")],
|
||||||
|
expected_down_frames=[MockMetadataFrame, TextFrame],
|
||||||
|
expected_up_frames=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# service2 pushed metadata on StartFrame, but it should have been blocked
|
||||||
|
self.assertGreaterEqual(self.service2.metadata_push_count, 1)
|
||||||
|
# Only one MockMetadataFrame should have left (from service1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ from pipecat.frames.frames import (
|
|||||||
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
|
from pipecat.turns.user_start.min_words_user_turn_start_strategy import (
|
||||||
MinWordsUserTurnStartStrategy,
|
MinWordsUserTurnStartStrategy,
|
||||||
)
|
)
|
||||||
|
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
|
||||||
from pipecat.turns.user_turn_controller import UserTurnController
|
from pipecat.turns.user_turn_controller import UserTurnController
|
||||||
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies
|
from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies, UserTurnStrategies
|
||||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||||
|
|
||||||
USER_TURN_STOP_TIMEOUT = 0.2
|
USER_TURN_STOP_TIMEOUT = 0.2
|
||||||
|
TRANSCRIPTION_TIMEOUT = 0.1
|
||||||
|
|
||||||
|
|
||||||
class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
|
class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
|
||||||
@@ -31,7 +33,11 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||||
|
|
||||||
async def test_default_user_turn_strategies(self):
|
async def test_default_user_turn_strategies(self):
|
||||||
controller = UserTurnController(user_turn_strategies=UserTurnStrategies())
|
controller = UserTurnController(
|
||||||
|
user_turn_strategies=UserTurnStrategies(
|
||||||
|
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await controller.setup(self.task_manager)
|
await controller.setup(self.task_manager)
|
||||||
|
|
||||||
@@ -60,6 +66,8 @@ class TestUserTurnController(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
await controller.process_frame(VADUserStoppedSpeakingFrame())
|
await controller.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
# Wait for user_speech_timeout to elapse
|
||||||
|
await asyncio.sleep(TRANSCRIPTION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_stop)
|
self.assertTrue(should_stop)
|
||||||
|
|
||||||
async def test_user_turn_start_reset(self):
|
async def test_user_turn_start_reset(self):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pipecat.frames.frames import (
|
|||||||
)
|
)
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
from pipecat.tests.utils import SleepFrame, run_test
|
from pipecat.tests.utils import SleepFrame, run_test
|
||||||
from pipecat.turns.user_stop import TranscriptionUserTurnStopStrategy
|
from pipecat.turns.user_stop import SpeechTimeoutUserTurnStopStrategy
|
||||||
from pipecat.turns.user_turn_processor import UserTurnProcessor
|
from pipecat.turns.user_turn_processor import UserTurnProcessor
|
||||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||||
|
|
||||||
@@ -26,7 +26,11 @@ TRANSCRIPTION_TIMEOUT = 0.1
|
|||||||
|
|
||||||
class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
|
class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_default_user_turn_strategies(self):
|
async def test_default_user_turn_strategies(self):
|
||||||
user_turn_processor = UserTurnProcessor(user_turn_strategies=UserTurnStrategies())
|
user_turn_processor = UserTurnProcessor(
|
||||||
|
user_turn_strategies=UserTurnStrategies(
|
||||||
|
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
should_stop = None
|
should_stop = None
|
||||||
@@ -48,6 +52,8 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
|
|||||||
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
||||||
SleepFrame(),
|
SleepFrame(),
|
||||||
VADUserStoppedSpeakingFrame(),
|
VADUserStoppedSpeakingFrame(),
|
||||||
|
# Wait for user_speech_timeout to elapse
|
||||||
|
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.1),
|
||||||
]
|
]
|
||||||
expected_down_frames = [
|
expected_down_frames = [
|
||||||
VADUserStartedSpeakingFrame,
|
VADUserStartedSpeakingFrame,
|
||||||
@@ -109,7 +115,7 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
|
|||||||
async def test_user_turn_stop_timeout_transcription(self):
|
async def test_user_turn_stop_timeout_transcription(self):
|
||||||
user_turn_processor = UserTurnProcessor(
|
user_turn_processor = UserTurnProcessor(
|
||||||
user_turn_strategies=UserTurnStrategies(
|
user_turn_strategies=UserTurnStrategies(
|
||||||
stop=[TranscriptionUserTurnStopStrategy(timeout=TRANSCRIPTION_TIMEOUT)],
|
stop=[SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=TRANSCRIPTION_TIMEOUT)],
|
||||||
),
|
),
|
||||||
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
|
user_turn_stop_timeout=USER_TURN_STOP_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -135,13 +141,13 @@ class TestUserTurnProcessor(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
pipeline = Pipeline([user_turn_processor])
|
pipeline = Pipeline([user_turn_processor])
|
||||||
|
|
||||||
|
# Transcript arrives before VAD stop, then we wait for user_speech_timeout
|
||||||
frames_to_send = [
|
frames_to_send = [
|
||||||
VADUserStartedSpeakingFrame(),
|
VADUserStartedSpeakingFrame(),
|
||||||
VADUserStoppedSpeakingFrame(),
|
|
||||||
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
|
|
||||||
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
TranscriptionFrame(text="Hello!", user_id="", timestamp="now"),
|
||||||
SleepFrame(sleep=USER_TURN_STOP_TIMEOUT - 0.1),
|
VADUserStoppedSpeakingFrame(),
|
||||||
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT),
|
# Wait for user_speech_timeout (TRANSCRIPTION_TIMEOUT=0.1s) to elapse
|
||||||
|
SleepFrame(sleep=TRANSCRIPTION_TIMEOUT + 0.05),
|
||||||
]
|
]
|
||||||
await run_test(
|
await run_test(
|
||||||
pipeline,
|
pipeline,
|
||||||
|
|||||||
@@ -9,25 +9,38 @@ import unittest
|
|||||||
|
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
InterimTranscriptionFrame,
|
InterimTranscriptionFrame,
|
||||||
|
STTMetadataFrame,
|
||||||
TranscriptionFrame,
|
TranscriptionFrame,
|
||||||
UserStartedSpeakingFrame,
|
UserStartedSpeakingFrame,
|
||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
VADUserStartedSpeakingFrame,
|
VADUserStartedSpeakingFrame,
|
||||||
VADUserStoppedSpeakingFrame,
|
VADUserStoppedSpeakingFrame,
|
||||||
)
|
)
|
||||||
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, TranscriptionUserTurnStopStrategy
|
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy, SpeechTimeoutUserTurnStopStrategy
|
||||||
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams
|
||||||
|
|
||||||
AGGREGATION_TIMEOUT = 0.1
|
AGGREGATION_TIMEOUT = 0.1
|
||||||
|
# Use 0 STT timeout for deterministic test timing
|
||||||
|
STT_TIMEOUT = 0.0
|
||||||
|
|
||||||
|
|
||||||
class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
class TestSpeechTimeoutUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
||||||
async def asyncSetUp(self) -> None:
|
async def asyncSetUp(self) -> None:
|
||||||
self.task_manager = TaskManager()
|
self.task_manager = TaskManager()
|
||||||
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
self.task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop()))
|
||||||
|
|
||||||
|
async def _create_strategy(self, user_speech_timeout=AGGREGATION_TIMEOUT):
|
||||||
|
"""Create strategy and configure STT timeout via metadata frame."""
|
||||||
|
strategy = SpeechTimeoutUserTurnStopStrategy(user_speech_timeout=user_speech_timeout)
|
||||||
|
await strategy.setup(self.task_manager)
|
||||||
|
# Set STT timeout via metadata frame (as would happen in real pipeline)
|
||||||
|
await strategy.process_frame(
|
||||||
|
STTMetadataFrame(service_name="test", ttfs_p99_latency=STT_TIMEOUT)
|
||||||
|
)
|
||||||
|
return strategy
|
||||||
|
|
||||||
async def test_ste(self):
|
async def test_ste(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy()
|
strategy = await self._create_strategy()
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -46,13 +59,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# E
|
# E
|
||||||
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes in between user started/stopped and there are not
|
# Transcription came in between user started/stopped. Now we wait for
|
||||||
# interim, we just trigger bot speech.
|
# timeout before triggering.
|
||||||
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_site(self):
|
async def test_site(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy()
|
strategy = await self._create_strategy()
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -77,13 +92,15 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# E
|
# E
|
||||||
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes in between user started/stopped, so we trigger
|
# Transcription came in between user started/stopped. Now we wait for
|
||||||
# speech right away.
|
# timeout before triggering.
|
||||||
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_st1iest2e(self):
|
async def test_st1iest2e(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy()
|
strategy = await self._create_strategy()
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -122,15 +139,14 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# E
|
# E
|
||||||
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# There was an interim before the first user stopped speaking, then we
|
# Now we wait for timeout before triggering.
|
||||||
# got a transcription comes in between user started/stopped, so we
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
# trigger speech right away.
|
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_siet(self):
|
async def test_siet(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -163,8 +179,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_sieit(self):
|
async def test_sieit(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -205,8 +220,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_set(self):
|
async def test_set(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -235,8 +249,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_seit(self):
|
async def test_seit(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -271,8 +284,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_st1et2(self):
|
async def test_st1et2(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -291,26 +303,37 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
# E
|
# E
|
||||||
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes between user start/stopped speaking, we need to
|
# Transcription came between user start/stopped speaking, wait for timeout.
|
||||||
# trigger speech right away.
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
|
# Reset for next turn (in real usage, UserTurnController would do this)
|
||||||
|
await strategy.reset()
|
||||||
|
|
||||||
|
# S - new turn starts
|
||||||
|
await strategy.process_frame(VADUserStartedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# T2
|
# T2
|
||||||
await strategy.process_frame(
|
await strategy.process_frame(
|
||||||
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
|
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
|
||||||
)
|
)
|
||||||
self.assertIsNone(should_start)
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
|
# E
|
||||||
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes after user stopped speaking, we need to wait for
|
# Transcription comes after user stopped speaking, we need to wait for
|
||||||
# at least the aggregation timeout.
|
# at least the aggregation timeout.
|
||||||
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_set1t2(self):
|
async def test_set1t2(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -343,8 +366,7 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_siet1it2(self):
|
async def test_siet1it2(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -388,8 +410,8 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_t(self):
|
async def test_t(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
"""Transcription without VAD - uses fallback timeout."""
|
||||||
await strategy.setup(self.task_manager)
|
strategy = await self._create_strategy()
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -402,14 +424,13 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
|
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
|
||||||
self.assertIsNone(should_start)
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes after user stopped speaking, we need to wait for
|
# Transcription without VAD triggers fallback timeout.
|
||||||
# at least the aggregation timeout.
|
|
||||||
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_it(self):
|
async def test_it(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
"""Interim + Transcription without VAD - uses fallback timeout."""
|
||||||
await strategy.setup(self.task_manager)
|
strategy = await self._create_strategy()
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -427,14 +448,12 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
|
await strategy.process_frame(TranscriptionFrame(text="Hello!", user_id="cat", timestamp=""))
|
||||||
self.assertIsNone(should_start)
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Transcription comes after user stopped speaking, we need to wait for
|
# Transcription without VAD triggers fallback timeout.
|
||||||
# at least the aggregation timeout.
|
|
||||||
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
async def test_sie_delay_it(self):
|
async def test_sie_delay_it(self):
|
||||||
strategy = TranscriptionUserTurnStopStrategy(timeout=AGGREGATION_TIMEOUT)
|
strategy = await self._create_strategy()
|
||||||
await strategy.setup(self.task_manager)
|
|
||||||
|
|
||||||
should_start = None
|
should_start = None
|
||||||
|
|
||||||
@@ -456,23 +475,22 @@ class TestTranscriptionUserTurnStopStrategy(unittest.IsolatedAsyncioTestCase):
|
|||||||
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
await strategy.process_frame(VADUserStoppedSpeakingFrame())
|
||||||
self.assertIsNone(should_start)
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# Delay
|
# Delay - timeout expires but no transcript yet
|
||||||
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
||||||
|
# Still no trigger because no transcript received
|
||||||
|
self.assertIsNone(should_start)
|
||||||
|
|
||||||
# I
|
# I
|
||||||
await strategy.process_frame(
|
await strategy.process_frame(
|
||||||
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
|
InterimTranscriptionFrame(text="How", user_id="cat", timestamp="")
|
||||||
)
|
)
|
||||||
|
|
||||||
# T
|
# T (finalized) - triggers immediately since timeout already elapsed
|
||||||
await strategy.process_frame(
|
await strategy.process_frame(
|
||||||
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="")
|
TranscriptionFrame(text="How are you?", user_id="cat", timestamp="", finalized=True)
|
||||||
)
|
)
|
||||||
self.assertIsNone(should_start)
|
|
||||||
|
|
||||||
# Transcription comes after user stopped speaking, we need to wait for
|
# Finalized transcript received after timeout, triggers immediately
|
||||||
# at least the aggregation timeout.
|
|
||||||
await asyncio.sleep(AGGREGATION_TIMEOUT + 0.1)
|
|
||||||
self.assertTrue(should_start)
|
self.assertTrue(should_start)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user