Add VADUserStartedSpeakingFrame and VADUserStoppedSpeakingFrame

This commit is contained in:
Mark Backman
2025-04-25 09:02:04 -04:00
parent 7cfefe4f84
commit b298376766
4 changed files with 54 additions and 5 deletions

View File

@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added `VADUserStartedSpeakingFrame` and `VADUserStoppedSpeakingFrame`,
indicating when the VAD detected the user to start and stop speaking. These
events are helpful when using smart turn detection, as the user's stop time
can differ from when their turn ends (signified by UserStoppedSpeakingFrame).
- Added `TranslationFrame`, a new frame type that contains a translated - Added `TranslationFrame`, a new frame type that contains a translated
transcription. transcription.

View File

@@ -6,7 +6,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Tuple
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
@@ -88,12 +88,24 @@ class VADAnalyzer(ABC):
volume = calculate_audio_volume(audio, self.sample_rate) volume = calculate_audio_volume(audio, self.sample_rate)
return exp_smoothing(volume, self._prev_volume, self._smoothing_factor) return exp_smoothing(volume, self._prev_volume, self._smoothing_factor)
def analyze_audio(self, buffer) -> VADState: def analyze_audio(self, buffer) -> Tuple[VADState, Optional[str]]:
"""Analyze audio for voice activity.
Args:
buffer: Audio buffer to analyze
Returns:
Tuple containing:
- VADState: Current VAD state
- Optional[str]: Event type if a speech event occurred ("speech_started",
"speech_stopped"), or None if no event occurred
"""
self._vad_buffer += buffer self._vad_buffer += buffer
event_type = None
num_required_bytes = self._vad_frames_num_bytes num_required_bytes = self._vad_frames_num_bytes
if len(self._vad_buffer) < num_required_bytes: if len(self._vad_buffer) < num_required_bytes:
return self._vad_state return self._vad_state, event_type
audio_frames = self._vad_buffer[:num_required_bytes] audio_frames = self._vad_buffer[:num_required_bytes]
self._vad_buffer = self._vad_buffer[num_required_bytes:] self._vad_buffer = self._vad_buffer[num_required_bytes:]
@@ -132,6 +144,7 @@ class VADAnalyzer(ABC):
): ):
self._vad_state = VADState.SPEAKING self._vad_state = VADState.SPEAKING
self._vad_starting_count = 0 self._vad_starting_count = 0
event_type = "speech_started"
if ( if (
self._vad_state == VADState.STOPPING self._vad_state == VADState.STOPPING
@@ -139,5 +152,6 @@ class VADAnalyzer(ABC):
): ):
self._vad_state = VADState.QUIET self._vad_state = VADState.QUIET
self._vad_stopping_count = 0 self._vad_stopping_count = 0
event_type = "speech_stopped"
return self._vad_state return self._vad_state, event_type

View File

@@ -587,6 +587,20 @@ class EmulateUserStoppedSpeakingFrame(SystemFrame):
pass pass
@dataclass
class VADUserStartedSpeakingFrame(SystemFrame):
"""Frame emitted when VAD detects the user has definitively started speaking."""
pass
@dataclass
class VADUserStoppedSpeakingFrame(SystemFrame):
"""Frame emitted when VAD detects the user has definitively stopped speaking."""
pass
@dataclass @dataclass
class BotInterruptionFrame(SystemFrame): class BotInterruptionFrame(SystemFrame):
"""Emitted by when the bot should be interrupted. This will mainly cause the """Emitted by when the bot should be interrupted. This will mainly cause the

View File

@@ -32,6 +32,8 @@ from pipecat.frames.frames import (
UserStartedSpeakingFrame, UserStartedSpeakingFrame,
UserStoppedSpeakingFrame, UserStoppedSpeakingFrame,
VADParamsUpdateFrame, VADParamsUpdateFrame,
VADUserStartedSpeakingFrame,
VADUserStoppedSpeakingFrame,
) )
from pipecat.metrics.metrics import MetricsData from pipecat.metrics.metrics import MetricsData
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@@ -228,9 +230,13 @@ class BaseInputTransport(FrameProcessor):
async def _vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState: async def _vad_analyze(self, audio_frame: InputAudioRawFrame) -> VADState:
state = VADState.QUIET state = VADState.QUIET
if self.vad_analyzer: if self.vad_analyzer:
state = await self.get_event_loop().run_in_executor( state, event_type = await self.get_event_loop().run_in_executor(
self._executor, self.vad_analyzer.analyze_audio, audio_frame.audio self._executor, self.vad_analyzer.analyze_audio, audio_frame.audio
) )
if event_type:
await self._handle_vad_event(event_type)
return state return state
async def _handle_vad(self, audio_frame: InputAudioRawFrame, vad_state: VADState): async def _handle_vad(self, audio_frame: InputAudioRawFrame, vad_state: VADState):
@@ -260,6 +266,16 @@ class BaseInputTransport(FrameProcessor):
vad_state = new_vad_state vad_state = new_vad_state
return vad_state return vad_state
async def _handle_vad_event(self, event_type: str):
"""Handle VAD speech events by creating and pushing appropriate frames."""
if event_type == "speech_started":
logger.debug("VAD detected definitive speech start")
await self.push_frame(VADUserStartedSpeakingFrame())
elif event_type == "speech_stopped":
logger.debug("VAD detected definitive speech stop")
await self.push_frame(VADUserStoppedSpeakingFrame())
async def _handle_end_of_turn(self): async def _handle_end_of_turn(self):
if self.turn_analyzer: if self.turn_analyzer:
state, prediction = await self.turn_analyzer.analyze_end_of_turn() state, prediction = await self.turn_analyzer.analyze_end_of_turn()