334 lines
12 KiB
Python
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()
|