Files
pipecat/scripts/krisp/test_krisp_viva_turn_audiofile.py

334 lines
12 KiB
Python

#!/usr/bin/env python3
"""Standalone script to test Krisp VIVA turn analyzer with real audio files.
This script processes audio files through Krisp VIVA turn analyzer and analyzes
turn detection, allowing you to test turn detection on real audio data.
Usage:
python test_krisp_viva_turn_audiofile.py input.wav
python test_krisp_viva_turn_audiofile.py input.wav --threshold 0.7
python test_krisp_viva_turn_audiofile.py input.wav --frame-duration 20
Requirements:
pip install soundfile numpy pipecat-ai[krisp]
Set KRISP_VIVA_TURN_MODEL_PATH environment variable to point to your .kef model file
"""
import argparse
import asyncio
import os
import sys
import time
from pathlib import Path
try:
import numpy as np
import soundfile as sf # noqa: F401
from audio_file_utils import read_audio_file
except ImportError as e:
print(f"Error: Missing required dependencies: {e}")
print("Install with: pip install soundfile numpy")
sys.exit(1)
# Add src directory to Python path for development environment
script_dir = Path(__file__).parent
project_root = script_dir.parent.parent
src_dir = project_root / "src"
if src_dir.exists() and str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
# Import Krisp VIVA turn analyzer
try:
from pipecat.audio.krisp_instance import KRISP_SAMPLE_RATES
from pipecat.audio.turn.krisp_viva_turn import KrispTurnParams, KrispVivaTurn
except ImportError as e:
print(f"Error: Could not import Krisp VIVA turn analyzer: {e}")
print("Make sure pipecat-ai is installed: pip install pipecat-ai[krisp]")
sys.exit(1)
def validate_model_path():
"""Validate that the Krisp VIVA turn model path is set and exists."""
env_var = "KRISP_VIVA_TURN_MODEL_PATH"
model_path = os.getenv(env_var)
if not model_path:
print(f"Error: {env_var} environment variable not set")
print(f"Set it with: export {env_var}=/path/to/model.kef")
print(f"Or in PowerShell: $env:{env_var}='C:\\path\\to\\model.kef'")
sys.exit(1)
if not os.path.isfile(model_path):
print(f"Error: Model file not found: {model_path}")
sys.exit(1)
return model_path
async def analyze_audio_file(
input_path: str,
threshold: float = 0.5,
frame_duration_ms: int = 20,
chunk_duration_ms: int = 20,
verbose: bool = False,
output_file: str | None = None,
) -> None:
"""Analyze an audio file for turn detection using Krisp VIVA turn analyzer.
Args:
input_path: Path to input audio file
threshold: Probability threshold for turn completion (0.0 to 1.0)
frame_duration_ms: Frame duration in milliseconds for turn detection model
chunk_duration_ms: Processing chunk size in milliseconds
verbose: Show detailed processing information
output_file: Optional path to save turn probabilities (one per line)
"""
# Read and convert audio file
audio_data, sample_rate = read_audio_file(input_path, verbose=True)
# Validate model path
model_path = validate_model_path()
# Check if sample rate is supported
supported_rates = list(KRISP_SAMPLE_RATES.keys())
if sample_rate not in supported_rates:
print(f"Warning: Sample rate {sample_rate} not in supported rates {supported_rates}")
print("Resampling may be required. Continuing anyway...")
print(f"\nInitializing VIVA turn analyzer:")
print(f" - Model path: {model_path}")
print(f" - Threshold: {threshold}")
print(f" - Frame duration: {frame_duration_ms}ms")
print(f" - Sample rate: {sample_rate}Hz")
print(f" - Processing chunk size: {chunk_duration_ms}ms")
# Create turn analyzer instance
print("\nInitializing turn analyzer...")
init_start_time = time.time()
params = KrispTurnParams(threshold=threshold, frame_duration_ms=frame_duration_ms)
turn_analyzer = KrispVivaTurn(model_path=model_path, params=params)
init_duration = time.time() - init_start_time
print(f"Turn analyzer initialized in {init_duration * 1000:.2f}ms")
try:
# Set sample rate
print("\nSetting sample rate...")
set_rate_start_time = time.time()
turn_analyzer.set_sample_rate(sample_rate)
set_rate_duration = time.time() - set_rate_start_time
print(f"Sample rate set to {turn_analyzer.sample_rate}Hz")
print(f"set_sample_rate latency: {set_rate_duration * 1000:.2f}ms")
print("\nProcessing audio for turn detection...")
# Calculate exact frame size based on frame duration
# The Krisp Tt processor requires exact frame sizes matching the configured frame duration
frame_size_samples = int(sample_rate * frame_duration_ms / 1000)
print(f" Frame size: {frame_size_samples} samples ({frame_duration_ms}ms)")
turn_events = []
speech_segments = []
current_speech_start = None
all_probabilities = [] # Store all probabilities for output file
# Simple energy-based VAD (for demonstration)
energy_threshold = np.std(audio_data) * 0.1
# Buffer for incomplete frames - we need to send exact frame sizes
audio_buffer = np.array([], dtype=np.int16)
frames_processed = 0
# Process audio in chunks, buffering to ensure exact frame sizes
read_chunk_size = max(frame_size_samples, int(sample_rate * chunk_duration_ms / 1000))
for i in range(0, len(audio_data), read_chunk_size):
chunk = audio_data[i : i + read_chunk_size]
if len(chunk) == 0:
break
# Add chunk to buffer
audio_buffer = np.concatenate([audio_buffer, chunk])
# Process complete frames from buffer
while len(audio_buffer) >= frame_size_samples:
# Extract exactly one frame
frame_samples = audio_buffer[:frame_size_samples].copy()
audio_buffer = audio_buffer[frame_size_samples:]
# Calculate timestamp for this frame
timestamp = frames_processed * frame_duration_ms / 1000.0
frames_processed += 1
# Simple VAD: check if frame has significant energy
frame_energy = np.sqrt(np.mean(frame_samples.astype(np.float32) ** 2))
is_speech = frame_energy > energy_threshold
# Process frame through turn analyzer
frame_bytes = frame_samples.tobytes()
end_of_turn_state = turn_analyzer.append_audio(frame_bytes, is_speech)
# Collect all probabilities from this call
# The TT model processes frames and returns probabilities per 100ms
# append_audio may process multiple frames, so collect all of them
all_probabilities.extend(turn_analyzer.frame_probabilities)
# Track speech segments
if is_speech:
if current_speech_start is None:
current_speech_start = timestamp
else:
if current_speech_start is not None:
speech_segments.append((current_speech_start, timestamp))
current_speech_start = None
# Track turn completion events
if end_of_turn_state.value == 1: # EndOfTurnState.COMPLETE
turn_events.append(
{
"timestamp": timestamp,
"speech_triggered": turn_analyzer.speech_triggered,
}
)
if verbose:
print(f" Turn completed at {timestamp:.2f}s")
# Progress indicator
if i % (read_chunk_size * 50) == 0:
progress = (i / len(audio_data)) * 100
print(f" Progress: {progress:.1f}%", end="\r")
# Process any remaining incomplete frame (if buffer has data)
if len(audio_buffer) > 0:
if verbose:
print(
f"\n Warning: {len(audio_buffer)} samples remaining (incomplete frame, will be discarded)"
)
print(f" Progress: 100.0%")
# Final speech segment if still speaking
if current_speech_start is not None:
speech_segments.append((current_speech_start, len(audio_data) / sample_rate))
# Print results
print("\n" + "=" * 60)
print("Turn Detection Results:")
print("=" * 60)
print(f"\nSpeech Segments Detected: {len(speech_segments)}")
for i, (start, end) in enumerate(speech_segments, 1):
duration = end - start
print(f" Segment {i}: {start:.2f}s - {end:.2f}s (duration: {duration:.2f}s)")
print(f"\nTurn Completion Events: {len(turn_events)}")
for i, event in enumerate(turn_events, 1):
print(f" Turn {i} completed at {event['timestamp']:.2f}s")
print(f"\nFinal State:")
print(f" Speech triggered: {turn_analyzer.speech_triggered}")
print(f" Sample rate: {turn_analyzer.sample_rate}Hz")
print(f" Total probabilities collected: {len(all_probabilities)}")
if len(turn_events) == 0:
print("\n ⚠️ No turn completion events detected.")
print(" This could mean:")
print(" - The audio doesn't contain clear turn boundaries")
print(" - The threshold is too high")
print(" - The model needs different parameters")
# Save probabilities to output file if specified
if output_file:
with open(output_file, "w") as f:
for prob in all_probabilities:
f.write(f"{prob}\n")
print(f"\n📄 Turn probabilities saved to: {output_file}")
print(f" Total frames: {len(all_probabilities)}")
print("\n✅ Analysis complete!")
finally:
# Cleanup
turn_analyzer.clear()
print("Turn analyzer cleared.")
def main():
parser = argparse.ArgumentParser(
description="Test Krisp VIVA turn analyzer with real audio files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python test_krisp_viva_turn_audiofile.py conversation.wav
python test_krisp_viva_turn_audiofile.py input.wav --threshold 0.7
python test_krisp_viva_turn_audiofile.py input.wav --frame-duration 20
Supported audio formats: WAV, FLAC, OGG, etc. (via soundfile)
Supported sample rates: 8000, 16000, 24000, 32000, 44100, 48000 Hz
Note: Set KRISP_VIVA_TURN_MODEL_PATH environment variable to point to your .kef model file
""",
)
parser.add_argument("input", help="Input audio file path")
parser.add_argument(
"--threshold",
type=float,
default=0.5,
help="Probability threshold for turn completion (0.0 to 1.0, default: 0.5)",
)
parser.add_argument(
"--frame-duration",
type=int,
default=20,
choices=[10, 15, 20, 30, 32],
help="Frame duration in milliseconds for turn detection model (default: 20)",
)
parser.add_argument(
"--chunk-duration",
type=int,
default=20,
help="Processing chunk size in milliseconds (default: 20)",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show detailed processing information",
)
parser.add_argument(
"-o",
"--output",
type=str,
default=None,
help="Output file path to save turn probabilities (.tt format, one probability per line)",
)
args = parser.parse_args()
# Validate input file exists
if not os.path.exists(args.input):
print(f"Error: Input file not found: {args.input}")
sys.exit(1)
# Validate threshold
if not 0.0 <= args.threshold <= 1.0:
print(f"Error: Threshold must be between 0.0 and 1.0, got {args.threshold}")
sys.exit(1)
# Process the audio
asyncio.run(
analyze_audio_file(
args.input,
threshold=args.threshold,
frame_duration_ms=args.frame_duration,
chunk_duration_ms=args.chunk_duration,
verbose=args.verbose,
output_file=args.output,
)
)
if __name__ == "__main__":
main()