allow custom interruption strategies

This commit is contained in:
Aleix Conchillo Flaqué
2025-06-01 13:54:57 -07:00
parent 13546d5e8f
commit 5512de3221
10 changed files with 140 additions and 52 deletions

View File

@@ -26,12 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added OpenTelemetry tracing for `GeminiMultimodalLiveLLMService` and - Added OpenTelemetry tracing for `GeminiMultimodalLiveLLMService` and
`OpenAIRealtimeBetaLLMService`. `OpenAIRealtimeBetaLLMService`.
- Added `interruption_strategies` to `PipelineParams` using - Added initial support for interruption strategies, which determine if the user
`MinWordsInterruptionStrategy` to specify minimum words required to interrupt should interrupt the bot while the bot is speaking. Interruption strategies
the bot when it's speaking. Use can be based on factors such as audio volume or the number of words spoken by
`interruption_strategies=[MinWordsInterruptionStrategy(min_words=N)]` to the user. These can be specified via the new `interruption_strategies` field
require users to speak at least N words before interrupting. If not in `PipelineParams`. A new `MinWordsInterruptionStrategy` strategy has been
specified, the normal interruption behavior applies. introduced which triggers an interruption if the user has spoken a minimum
number of words. If no interruption strategies are specified, the normal
interruption behavior applies. If multiple strategies are provided, the first
one that evaluates to true will trigger the interruption.
- `BaseInputTransport` now handles `StopFrame`. When a `StopFrame` is received - `BaseInputTransport` now handles `StopFrame`. When a `StopFrame` is received
the transport will pause sending frames downstream until a new `StartFrame` is the transport will pause sending frames downstream until a new `StartFrame` is

View File

@@ -0,0 +1,38 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
from abc import ABC, abstractmethod
class BaseInterruptionStrategy(ABC):
"""This is a base class for interruption strategies. Interruption strategies
decide when the user can interrupt the bot while the bot is speaking. For
example, there could be strategies based on audio volume or strategies based
on the number of words the user spoke.
"""
async def append_audio(self, audio: bytes, sample_rate: int):
"""Appends audio to the strategy. Not all strategies handle audio."""
pass
async def append_text(self, text: str):
"""Appends text to the strategy. Not all strategies handle text."""
pass
@abstractmethod
async def should_interrupt(self) -> bool:
"""This is called when the user stops speaking and it's time to decide
whether the user should interrupt the bot. The decision will be based on
the aggregated audio and/or text.
"""
pass
@abstractmethod
async def reset(self):
"""Reset the current accumulated text and/or audio."""
pass

View File

@@ -0,0 +1,40 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
from loguru import logger
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
class MinWordsInterruptionStrategy(BaseInterruptionStrategy):
"""This is an interruption strategy based on a minimum number of words said
by the user. That is, the strategy will be true if the user has said at
least that amount of words.
"""
def __init__(self, *, min_words: int):
super().__init__()
self._min_words = min_words
self._text = ""
async def append_text(self, text: str):
"""Appends text for later analysis. Not all strategies need to handle
text.
"""
self._text += text
async def should_interrupt(self) -> bool:
word_count = len(self._text.split())
interrupt = word_count >= self._min_words
logger.debug(
f"should_interrupt={interrupt} num_spoken_words={word_count} min_words={self._min_words}"
)
return interrupt
async def reset(self):
self._text = ""

View File

@@ -19,6 +19,7 @@ from typing import (
Tuple, Tuple,
) )
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.audio.vad.vad_analyzer import VADParams from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.metrics.metrics import MetricsData from pipecat.metrics.metrics import MetricsData
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
@@ -439,28 +440,6 @@ class OutputDTMFFrame(DTMFFrame, DataFrame):
# #
@dataclass
class InterruptionStrategy:
"""Base class for interruption strategies."""
pass
@dataclass
class MinWordsInterruptionStrategy(InterruptionStrategy):
"""Strategy for interruption behavior based on a minimum number of words spoken by the user.
Args:
min_words: If set, user must speak at least this many words to interrupt
"""
min_words: int
def __post_init__(self):
if self.min_words <= 0:
raise ValueError("min_words must be greater than 0")
@dataclass @dataclass
class StartFrame(SystemFrame): class StartFrame(SystemFrame):
"""This is the first frame that should be pushed down a pipeline.""" """This is the first frame that should be pushed down a pipeline."""
@@ -471,7 +450,7 @@ class StartFrame(SystemFrame):
enable_metrics: bool = False enable_metrics: bool = False
enable_usage_metrics: bool = False enable_usage_metrics: bool = False
report_only_initial_ttfb: bool = False report_only_initial_ttfb: bool = False
interruption_strategies: Optional[Sequence[InterruptionStrategy]] = None interruption_strategies: List[BaseInterruptionStrategy] = field(default_factory=list)
@dataclass @dataclass

View File

@@ -11,6 +11,7 @@ from typing import Any, AsyncIterable, Dict, Iterable, List, Optional, Sequence,
from loguru import logger from loguru import logger
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.clocks.base_clock import BaseClock from pipecat.clocks.base_clock import BaseClock
from pipecat.clocks.system_clock import SystemClock from pipecat.clocks.system_clock import SystemClock
from pipecat.frames.frames import ( from pipecat.frames.frames import (
@@ -22,7 +23,6 @@ from pipecat.frames.frames import (
ErrorFrame, ErrorFrame,
Frame, Frame,
HeartbeatFrame, HeartbeatFrame,
InterruptionStrategy,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
MetricsFrame, MetricsFrame,
StartFrame, StartFrame,
@@ -75,7 +75,7 @@ class PipelineParams(BaseModel):
report_only_initial_ttfb: bool = False report_only_initial_ttfb: bool = False
send_initial_empty_metrics: bool = True send_initial_empty_metrics: bool = True
start_metadata: Dict[str, Any] = Field(default_factory=dict) start_metadata: Dict[str, Any] = Field(default_factory=dict)
interruption_strategies: Optional[Sequence[InterruptionStrategy]] = None interruption_strategies: List[BaseInterruptionStrategy] = Field(default_factory=list)
class PipelineTaskSource(FrameProcessor): class PipelineTaskSource(FrameProcessor):

View File

@@ -11,6 +11,7 @@ from typing import Dict, List, Literal, Optional, Set
from loguru import logger from loguru import logger
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotInterruptionFrame, BotInterruptionFrame,
BotStartedSpeakingFrame, BotStartedSpeakingFrame,
@@ -24,6 +25,7 @@ from pipecat.frames.frames import (
FunctionCallInProgressFrame, FunctionCallInProgressFrame,
FunctionCallResultFrame, FunctionCallResultFrame,
FunctionCallsStartedFrame, FunctionCallsStartedFrame,
InputAudioRawFrame,
InterimTranscriptionFrame, InterimTranscriptionFrame,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
@@ -33,7 +35,6 @@ from pipecat.frames.frames import (
LLMSetToolChoiceFrame, LLMSetToolChoiceFrame,
LLMSetToolsFrame, LLMSetToolsFrame,
LLMTextFrame, LLMTextFrame,
MinWordsInterruptionStrategy,
OpenAILLMContextAssistantTimestampFrame, OpenAILLMContextAssistantTimestampFrame,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
@@ -296,6 +297,9 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
elif isinstance(frame, CancelFrame): elif isinstance(frame, CancelFrame):
await self._cancel(frame) await self._cancel(frame)
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, InputAudioRawFrame):
await self._handle_input_audio(frame)
await self.push_frame(frame, direction)
elif isinstance(frame, UserStartedSpeakingFrame): elif isinstance(frame, UserStartedSpeakingFrame):
await self._handle_user_started_speaking(frame) await self._handle_user_started_speaking(frame)
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -332,10 +336,10 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
await self.push_frame(frame) await self.push_frame(frame)
async def push_aggregation(self): async def push_aggregation(self):
"""Pushes the current aggregation based on interruption configuration and conditions.""" """Pushes the current aggregation based on interruption strategies and conditions."""
if len(self._aggregation) > 0: if len(self._aggregation) > 0:
if self.interruption_strategies and self._bot_speaking: if self.interruption_strategies and self._bot_speaking:
should_interrupt = self._should_interrupt_based_on_strategies() should_interrupt = await self._should_interrupt_based_on_strategies()
if should_interrupt: if should_interrupt:
logger.debug( logger.debug(
@@ -351,23 +355,19 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
# No interruption config - normal behavior (always push aggregation) # No interruption config - normal behavior (always push aggregation)
await self._process_aggregation() await self._process_aggregation()
def _should_interrupt_based_on_strategies(self) -> bool: async def _should_interrupt_based_on_strategies(self) -> bool:
"""Check if interruption should occur based on configured strategies.""" """Check if interruption should occur based on configured strategies."""
if not self.interruption_strategies:
return False
# Check strategies one by one until first match async def should_interrupt(strategy: BaseInterruptionStrategy):
for strategy in self.interruption_strategies: await strategy.append_text(self._aggregation)
if isinstance(strategy, MinWordsInterruptionStrategy): return await strategy.should_interrupt()
if self._should_interrupt_min_words(strategy):
return True
return False result = any([await should_interrupt(s) for s in self._interruption_strategies])
def _should_interrupt_min_words(self, strategy: MinWordsInterruptionStrategy) -> bool: # Reset all strategies.
"""Check if word count threshold is met.""" [await s.reset() for s in self._interruption_strategies]
word_count = len(self._aggregation.split())
return word_count >= strategy.min_words return result
async def _start(self, frame: StartFrame): async def _start(self, frame: StartFrame):
self._create_aggregation_task() self._create_aggregation_task()
@@ -378,6 +378,10 @@ class LLMUserContextAggregator(LLMContextResponseAggregator):
async def _cancel(self, frame: CancelFrame): async def _cancel(self, frame: CancelFrame):
await self._cancel_aggregation_task() await self._cancel_aggregation_task()
async def _handle_input_audio(self, frame: InputAudioRawFrame):
for s in self.interruption_strategies:
await s.append_audio(frame.audio, frame.sample_rate)
async def _handle_user_started_speaking(self, frame: UserStartedSpeakingFrame): async def _handle_user_started_speaking(self, frame: UserStartedSpeakingFrame):
self._user_speaking = True self._user_speaking = True
self._waiting_for_aggregation = True self._waiting_for_aggregation = True

View File

@@ -7,16 +7,16 @@
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Awaitable, Callable, Coroutine, Optional, Sequence from typing import Awaitable, Callable, Coroutine, List, Optional, Sequence
from loguru import logger from loguru import logger
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
from pipecat.clocks.base_clock import BaseClock from pipecat.clocks.base_clock import BaseClock
from pipecat.frames.frames import ( from pipecat.frames.frames import (
CancelFrame, CancelFrame,
ErrorFrame, ErrorFrame,
Frame, Frame,
InterruptionStrategy,
StartFrame, StartFrame,
StartInterruptionFrame, StartInterruptionFrame,
StopInterruptionFrame, StopInterruptionFrame,
@@ -68,7 +68,7 @@ class FrameProcessor(BaseObject):
self._enable_metrics = False self._enable_metrics = False
self._enable_usage_metrics = False self._enable_usage_metrics = False
self._report_only_initial_ttfb = False self._report_only_initial_ttfb = False
self._interruption_strategies: Optional[Sequence[InterruptionStrategy]] = None self._interruption_strategies: List[BaseInterruptionStrategy] = []
# Indicates whether we have received the StartFrame. # Indicates whether we have received the StartFrame.
self.__started = False self.__started = False
@@ -122,7 +122,7 @@ class FrameProcessor(BaseObject):
return self._report_only_initial_ttfb return self._report_only_initial_ttfb
@property @property
def interruption_strategies(self) -> Optional[Sequence[InterruptionStrategy]]: def interruption_strategies(self) -> Sequence[BaseInterruptionStrategy]:
return self._interruption_strategies return self._interruption_strategies
def can_generate_metrics(self) -> bool: def can_generate_metrics(self) -> bool:

View File

@@ -246,7 +246,7 @@ class BaseInputTransport(FrameProcessor):
# 1. No interruption config is set, OR # 1. No interruption config is set, OR
# 2. Interruption config is set but bot is not speaking # 2. Interruption config is set but bot is not speaking
should_push_immediate_interruption = ( should_push_immediate_interruption = (
self.interruption_strategies is None or not self._bot_speaking not self.interruption_strategies or not self._bot_speaking
) )
# Make sure we notify about interruptions quickly out-of-band. # Make sure we notify about interruptions quickly out-of-band.

View File

@@ -0,0 +1,24 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest
from pipecat.audio.interruptions.min_words_interruption_strategy import MinWordsInterruptionStrategy
class TestInterruptionStrategy(unittest.IsolatedAsyncioTestCase):
async def test_min_words(self):
strategy = MinWordsInterruptionStrategy(min_words=2)
await strategy.append_text("Hello")
self.assertEqual(await strategy.should_interrupt(), False)
await strategy.append_text(" there!")
self.assertEqual(await strategy.should_interrupt(), True)
# Reset and check again
await strategy.reset()
await strategy.append_text("Hello!")
self.assertEqual(await strategy.should_interrupt(), False)
await strategy.append_text(" How are you?")
self.assertEqual(await strategy.should_interrupt(), True)