Add a SmartTurnMetricsData class, emitted by Metrics Frame in response to smart turn responses
This commit is contained in:
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added `SmartTurnMetricsData`, which contains end-of-turn prediction metrics,
|
||||||
|
to the `MetricsFrame`. Using `MetricsFrame`, you can now retrieve prediction
|
||||||
|
confidence scores and processing time metrics from the smart turn analyzers.
|
||||||
|
|
||||||
- Added support for Application Default Credentials in Google services,
|
- Added support for Application Default Credentials in Google services,
|
||||||
`GoogleSTTService`, `GoogleTTSService`, and `GoogleVertexLLMService`.
|
`GoogleSTTService`, `GoogleTTSService`, and `GoogleVertexLLMService`.
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Dict, Optional
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnState
|
from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnState
|
||||||
|
from pipecat.metrics.metrics import MetricsData, SmartTurnMetricsData
|
||||||
|
|
||||||
# Default timing parameters
|
# Default timing parameters
|
||||||
STOP_SECS = 3
|
STOP_SECS = 3
|
||||||
@@ -61,7 +62,6 @@ class BaseSmartTurn(BaseTurnAnalyzer):
|
|||||||
self._speech_triggered = True
|
self._speech_triggered = True
|
||||||
if self._speech_start_time is None:
|
if self._speech_start_time is None:
|
||||||
self._speech_start_time = time.time()
|
self._speech_start_time = time.time()
|
||||||
logger.debug(f"Speech started at {self._speech_start_time}")
|
|
||||||
else:
|
else:
|
||||||
if self._speech_triggered:
|
if self._speech_triggered:
|
||||||
chunk_duration_ms = len(audio_int16) / (self._sample_rate / 1000)
|
chunk_duration_ms = len(audio_int16) / (self._sample_rate / 1000)
|
||||||
@@ -87,28 +87,25 @@ class BaseSmartTurn(BaseTurnAnalyzer):
|
|||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def analyze_end_of_turn(self) -> EndOfTurnState:
|
def analyze_end_of_turn(self) -> Tuple[EndOfTurnState, Optional[MetricsData]]:
|
||||||
logger.debug("Analyzing End of Turn...")
|
state, result = self._process_speech_segment(self._audio_buffer)
|
||||||
state = self._process_speech_segment(self._audio_buffer)
|
|
||||||
if state == EndOfTurnState.COMPLETE or USE_ONLY_LAST_VAD_SEGMENT:
|
if state == EndOfTurnState.COMPLETE or USE_ONLY_LAST_VAD_SEGMENT:
|
||||||
self._clear(state)
|
self._clear(state)
|
||||||
logger.debug(f"End of Turn result: {state}")
|
logger.debug(f"End of Turn result: {state}")
|
||||||
return state
|
return state, result
|
||||||
|
|
||||||
def _clear(self, turn_state: EndOfTurnState):
|
def _clear(self, turn_state: EndOfTurnState):
|
||||||
# Reset internal state for next turn
|
|
||||||
logger.debug("Clearing audio buffer...")
|
|
||||||
# If the state is still incomplete, keep the _speech_triggered as True
|
# If the state is still incomplete, keep the _speech_triggered as True
|
||||||
self._speech_triggered = turn_state == EndOfTurnState.INCOMPLETE
|
self._speech_triggered = turn_state == EndOfTurnState.INCOMPLETE
|
||||||
self._audio_buffer = []
|
self._audio_buffer = []
|
||||||
self._speech_start_time = None
|
self._speech_start_time = None
|
||||||
self._silence_ms = 0
|
self._silence_ms = 0
|
||||||
|
|
||||||
def _process_speech_segment(self, audio_buffer) -> EndOfTurnState:
|
def _process_speech_segment(self, audio_buffer) -> Tuple[EndOfTurnState, Optional[MetricsData]]:
|
||||||
state = EndOfTurnState.INCOMPLETE
|
state = EndOfTurnState.INCOMPLETE
|
||||||
|
|
||||||
if not audio_buffer:
|
if not audio_buffer:
|
||||||
return state
|
return state, None
|
||||||
|
|
||||||
# Extract recent audio segment for prediction
|
# Extract recent audio segment for prediction
|
||||||
start_time = self._speech_start_time - (self._params.pre_speech_ms / 1000)
|
start_time = self._speech_start_time - (self._params.pre_speech_ms / 1000)
|
||||||
@@ -124,15 +121,13 @@ class BaseSmartTurn(BaseTurnAnalyzer):
|
|||||||
segment_audio_chunks = [chunk for _, chunk in audio_buffer[start_index : end_index + 1]]
|
segment_audio_chunks = [chunk for _, chunk in audio_buffer[start_index : end_index + 1]]
|
||||||
segment_audio = np.concatenate(segment_audio_chunks)
|
segment_audio = np.concatenate(segment_audio_chunks)
|
||||||
|
|
||||||
logger.debug(f"Segment audio chunks after start index: {len(segment_audio)}")
|
|
||||||
|
|
||||||
# Limit maximum duration
|
# Limit maximum duration
|
||||||
max_samples = int(self._params.max_duration_secs * self.sample_rate)
|
max_samples = int(self._params.max_duration_secs * self.sample_rate)
|
||||||
if len(segment_audio) > max_samples:
|
if len(segment_audio) > max_samples:
|
||||||
# slices the array to keep the last max_samples samples, discarding the earlier part.
|
# slices the array to keep the last max_samples samples, discarding the earlier part.
|
||||||
segment_audio = segment_audio[-max_samples:]
|
segment_audio = segment_audio[-max_samples:]
|
||||||
|
|
||||||
logger.debug(f"Segment audio chunks after limiting duration: {len(segment_audio)}")
|
result_data = None
|
||||||
|
|
||||||
if len(segment_audio) > 0:
|
if len(segment_audio) > 0:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
@@ -142,20 +137,33 @@ class BaseSmartTurn(BaseTurnAnalyzer):
|
|||||||
)
|
)
|
||||||
end_time = time.perf_counter()
|
end_time = time.perf_counter()
|
||||||
|
|
||||||
logger.debug("--------")
|
# Calculate processing time
|
||||||
logger.debug(f"Prediction: {'Complete' if result['prediction'] == 1 else 'Incomplete'}")
|
e2e_processing_time_ms = (end_time - start_time) * 1000
|
||||||
logger.debug(f"Probability of complete: {result['probability']:.4f}")
|
|
||||||
logger.debug(f"Prediction took {(end_time - start_time) * 1000:.2f}ms seconds")
|
|
||||||
else:
|
|
||||||
logger.debug(f"params: {self._params}, stop_ms: {self._stop_ms}")
|
|
||||||
logger.debug("Captured empty audio segment, skipping prediction.")
|
|
||||||
|
|
||||||
return state
|
# Prepare the result data
|
||||||
|
result_data = SmartTurnMetricsData(
|
||||||
|
processor="BaseSmartTurn",
|
||||||
|
is_complete=result["prediction"] == 1,
|
||||||
|
probability=result["probability"],
|
||||||
|
inference_time_ms=result.get("inference_time", 0) * 1000,
|
||||||
|
server_total_time_ms=result.get("total_time", 0) * 1000,
|
||||||
|
e2e_processing_time_ms=e2e_processing_time_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.trace(f"Prediction: {'Complete' if result_data.is_complete else 'Incomplete'}")
|
||||||
|
logger.trace(f"Probability of complete: {result_data.probability:.4f}")
|
||||||
|
logger.trace(f"Inference time: {result_data.inference_time_ms:.2f}ms")
|
||||||
|
logger.trace(f"Server total time: {result_data.server_total_time_ms:.2f}ms")
|
||||||
|
logger.trace(f"E2E processing time: {result_data.e2e_processing_time_ms:.2f}ms")
|
||||||
|
else:
|
||||||
|
logger.trace(f"params: {self._params}, stop_ms: {self._stop_ms}")
|
||||||
|
logger.trace("Captured empty audio segment, skipping prediction.")
|
||||||
|
|
||||||
|
return state, result_data
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _predict_endpoint(self, buffer: np.ndarray) -> Dict[str, any]:
|
def _predict_endpoint(self, buffer: np.ndarray) -> Dict[str, Any]:
|
||||||
"""
|
"""Abstract method to predict if a turn has ended based on audio.
|
||||||
Abstract method to predict if a turn has ended based on audio.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
buffer: Float32 numpy array of audio samples at 16kHz.
|
buffer: Float32 numpy array of audio samples at 16kHz.
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
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 pipecat.metrics.metrics import MetricsData
|
||||||
|
|
||||||
|
|
||||||
class EndOfTurnState(Enum):
|
class EndOfTurnState(Enum):
|
||||||
@@ -15,8 +17,10 @@ class EndOfTurnState(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class BaseTurnAnalyzer(ABC):
|
class BaseTurnAnalyzer(ABC):
|
||||||
"""
|
"""Abstract base class for analyzing user end of turn.
|
||||||
Abstract base class for analyzing user end of turn.
|
|
||||||
|
This class inherits from BaseObject to leverage its event handling system
|
||||||
|
while still defining an abstract interface through abstract methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, sample_rate: Optional[int] = None):
|
def __init__(self, *, sample_rate: Optional[int] = None):
|
||||||
@@ -25,8 +29,7 @@ class BaseTurnAnalyzer(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def sample_rate(self) -> int:
|
def sample_rate(self) -> int:
|
||||||
"""
|
"""Returns the current sample rate.
|
||||||
Returns the current sample rate.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: The effective sample rate for audio processing.
|
int: The effective sample rate for audio processing.
|
||||||
@@ -34,8 +37,7 @@ class BaseTurnAnalyzer(ABC):
|
|||||||
return self._sample_rate
|
return self._sample_rate
|
||||||
|
|
||||||
def set_sample_rate(self, sample_rate: int):
|
def set_sample_rate(self, sample_rate: int):
|
||||||
"""
|
"""Sets the sample rate for audio processing.
|
||||||
Sets the sample rate for audio processing.
|
|
||||||
|
|
||||||
If the initial sample rate was provided, it will use that; otherwise, it sets to
|
If the initial sample rate was provided, it will use that; otherwise, it sets to
|
||||||
the provided sample rate.
|
the provided sample rate.
|
||||||
@@ -48,8 +50,7 @@ class BaseTurnAnalyzer(ABC):
|
|||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def speech_triggered(self) -> bool:
|
def speech_triggered(self) -> bool:
|
||||||
"""
|
"""Determines if speech has been detected.
|
||||||
Determines if speech has been detected.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if speech is triggered, otherwise False.
|
bool: True if speech is triggered, otherwise False.
|
||||||
@@ -58,8 +59,7 @@ class BaseTurnAnalyzer(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
|
def append_audio(self, buffer: bytes, is_speech: bool) -> EndOfTurnState:
|
||||||
"""
|
"""Appends audio data for analysis.
|
||||||
Appends audio data for analysis.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
buffer (bytes): The audio data to append.
|
buffer (bytes): The audio data to append.
|
||||||
@@ -71,9 +71,8 @@ class BaseTurnAnalyzer(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def analyze_end_of_turn(self) -> EndOfTurnState:
|
def analyze_end_of_turn(self) -> Tuple[EndOfTurnState, Optional[MetricsData]]:
|
||||||
"""
|
"""Analyzes if an end of turn has occurred based on the audio input.
|
||||||
Analyzes if an end of turn has occurred based on the audio input.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
EndOfTurnState: The result of the end of turn analysis.
|
EndOfTurnState: The result of the end of turn analysis.
|
||||||
|
|||||||
@@ -30,3 +30,13 @@ class LLMUsageMetricsData(MetricsData):
|
|||||||
|
|
||||||
class TTSUsageMetricsData(MetricsData):
|
class TTSUsageMetricsData(MetricsData):
|
||||||
value: int
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
class SmartTurnMetricsData(MetricsData):
|
||||||
|
"""Metrics data for smart turn predictions."""
|
||||||
|
|
||||||
|
is_complete: bool
|
||||||
|
probability: float
|
||||||
|
inference_time_ms: float
|
||||||
|
server_total_time_ms: float
|
||||||
|
e2e_processing_time_ms: float
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Optional
|
from typing import Mapping, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from pipecat.audio.turn.base_turn_analyzer import BaseTurnAnalyzer, EndOfTurnState
|
from pipecat.audio.turn.base_turn_analyzer import (
|
||||||
|
BaseTurnAnalyzer,
|
||||||
|
EndOfTurnState,
|
||||||
|
)
|
||||||
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState
|
from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADState
|
||||||
from pipecat.frames.frames import (
|
from pipecat.frames.frames import (
|
||||||
BotInterruptionFrame,
|
BotInterruptionFrame,
|
||||||
@@ -21,6 +24,7 @@ from pipecat.frames.frames import (
|
|||||||
FilterUpdateSettingsFrame,
|
FilterUpdateSettingsFrame,
|
||||||
Frame,
|
Frame,
|
||||||
InputAudioRawFrame,
|
InputAudioRawFrame,
|
||||||
|
MetricsFrame,
|
||||||
StartFrame,
|
StartFrame,
|
||||||
StartInterruptionFrame,
|
StartInterruptionFrame,
|
||||||
StopInterruptionFrame,
|
StopInterruptionFrame,
|
||||||
@@ -29,6 +33,7 @@ from pipecat.frames.frames import (
|
|||||||
UserStoppedSpeakingFrame,
|
UserStoppedSpeakingFrame,
|
||||||
VADParamsUpdateFrame,
|
VADParamsUpdateFrame,
|
||||||
)
|
)
|
||||||
|
from pipecat.metrics.metrics import MetricsData, SmartTurnMetricsData
|
||||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
from pipecat.transports.base_transport import TransportParams
|
from pipecat.transports.base_transport import TransportParams
|
||||||
|
|
||||||
@@ -78,6 +83,7 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
# Configure End of turn analyzer.
|
# Configure End of turn analyzer.
|
||||||
if self._params.turn_analyzer:
|
if self._params.turn_analyzer:
|
||||||
self._params.turn_analyzer.set_sample_rate(self._sample_rate)
|
self._params.turn_analyzer.set_sample_rate(self._sample_rate)
|
||||||
|
|
||||||
# Start audio filter.
|
# Start audio filter.
|
||||||
if self._params.audio_in_filter:
|
if self._params.audio_in_filter:
|
||||||
await self._params.audio_in_filter.start(self._sample_rate)
|
await self._params.audio_in_filter.start(self._sample_rate)
|
||||||
@@ -216,9 +222,12 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
|
|
||||||
async def _handle_end_of_turn(self):
|
async def _handle_end_of_turn(self):
|
||||||
if self.turn_analyzer:
|
if self.turn_analyzer:
|
||||||
state = await self.get_event_loop().run_in_executor(
|
state, prediction = await self.get_event_loop().run_in_executor(
|
||||||
self._executor, self.turn_analyzer.analyze_end_of_turn
|
self._executor, self.turn_analyzer.analyze_end_of_turn
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await self._handle_prediction_result(prediction)
|
||||||
|
|
||||||
await self._handle_end_of_turn_complete(state)
|
await self._handle_end_of_turn_complete(state)
|
||||||
|
|
||||||
async def _handle_end_of_turn_complete(self, state: EndOfTurnState):
|
async def _handle_end_of_turn_complete(self, state: EndOfTurnState):
|
||||||
@@ -263,3 +272,11 @@ class BaseInputTransport(FrameProcessor):
|
|||||||
await self.push_frame(frame)
|
await self.push_frame(frame)
|
||||||
|
|
||||||
self._audio_in_queue.task_done()
|
self._audio_in_queue.task_done()
|
||||||
|
|
||||||
|
async def _handle_prediction_result(self, result: MetricsData):
|
||||||
|
"""Handle a prediction result event from the turn analyzer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: The prediction result MetricsData.
|
||||||
|
"""
|
||||||
|
await self.push_frame(MetricsFrame(data=[result]))
|
||||||
|
|||||||
Reference in New Issue
Block a user