Krisp VIVA SDK Filter and Turn support. (#3261)
* Krisp VIVA SDK Filter and Turn support. * Reverted the krisp_filter.py as it's already deprectaed. * enabled test with krisp_audio mock. * More review comment fixes. reverted the state logic in viva filter to be similar to the existing impl on main branch. Fixed tests, ruff, etc. * More review comments for Turn detection. removed integration tests. * Moved the SDK init/deinit into start/stop
This commit is contained in:
committed by
GitHub
parent
72a44c2fcd
commit
16819a5caa
148
scripts/krisp/audio_file_utils.py
Normal file
148
scripts/krisp/audio_file_utils.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Utility functions for reading and writing audio files in integration tests.
|
||||
|
||||
This module provides consistent audio file I/O operations for test scripts,
|
||||
handling format detection and conversion to int16 PCM format.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
|
||||
def read_audio_file(input_path: str, verbose: bool = False) -> Tuple[np.ndarray, int]:
|
||||
"""Read an audio file and convert to int16 mono format.
|
||||
|
||||
This function:
|
||||
- Detects the audio format from the file header
|
||||
- Reads PCM_16 files directly without conversion
|
||||
- Converts float formats by scaling to int16 range
|
||||
- Converts stereo to mono
|
||||
- Validates the audio data range
|
||||
|
||||
Args:
|
||||
input_path: Path to the input audio file
|
||||
verbose: If True, print detailed format information
|
||||
|
||||
Returns:
|
||||
Tuple of (audio_data, sample_rate) where audio_data is int16 mono
|
||||
|
||||
Raises:
|
||||
SystemExit: If the audio format is not supported
|
||||
"""
|
||||
if verbose:
|
||||
print(f"Loading audio from: {input_path}")
|
||||
|
||||
# Get audio file info to determine the format
|
||||
info = sf.info(input_path)
|
||||
if verbose:
|
||||
print(
|
||||
f"Audio file format: {info.subtype}, {info.channels} channel(s), {info.samplerate} Hz"
|
||||
)
|
||||
|
||||
# Read audio data based on the source format
|
||||
if info.subtype in ["PCM_16", "PCM_S16"]:
|
||||
# File is already int16, read directly to avoid unnecessary conversion
|
||||
audio_data, sample_rate = sf.read(input_path, dtype="int16")
|
||||
if verbose:
|
||||
print("Read as int16 (native format)")
|
||||
elif info.subtype in ["FLOAT", "DOUBLE"]:
|
||||
# File is float format, read as float32 and scale to int16
|
||||
audio_data, sample_rate = sf.read(input_path, dtype="float32")
|
||||
# Convert float32 (-1.0 to 1.0) to int16 (-32768 to 32767)
|
||||
audio_data = (audio_data * 32767).astype(np.int16)
|
||||
if verbose:
|
||||
print("Read as float32 and scaled to int16")
|
||||
else:
|
||||
print(f"Error: Unsupported audio format: {info.subtype}")
|
||||
print(f"Supported formats: PCM_16, PCM_S16, FLOAT, DOUBLE")
|
||||
sys.exit(1)
|
||||
|
||||
# Convert stereo to mono if needed
|
||||
if len(audio_data.shape) > 1:
|
||||
if verbose:
|
||||
print(f"Converting from {audio_data.shape[1]} channels to mono")
|
||||
if audio_data.dtype == np.int16:
|
||||
# For int16, convert to int32 for averaging to avoid overflow
|
||||
audio_data = audio_data.astype(np.int32).mean(axis=1).astype(np.int16)
|
||||
else:
|
||||
audio_data = audio_data.mean(axis=1).astype(np.int16)
|
||||
|
||||
# Verify the audio has proper range
|
||||
audio_max = abs(audio_data.max())
|
||||
audio_min = abs(audio_data.min())
|
||||
audio_range = max(audio_max, audio_min)
|
||||
|
||||
if audio_range < 100:
|
||||
print(
|
||||
f"⚠️ WARNING: Audio values are very small (max: {audio_data.max()}, min: {audio_data.min()})"
|
||||
)
|
||||
print(f" Expected int16 range: -32768 to 32767")
|
||||
print(f" This may indicate a format conversion issue.")
|
||||
elif verbose:
|
||||
print(f"Audio range: {audio_data.min()} to {audio_data.max()} ✓")
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f"Audio info: {len(audio_data)} samples, {sample_rate} Hz, {len(audio_data) / sample_rate:.2f} seconds"
|
||||
)
|
||||
|
||||
return audio_data, sample_rate
|
||||
|
||||
|
||||
def write_audio_file(
|
||||
output_path: str, audio_data: np.ndarray, sample_rate: int, verbose: bool = False
|
||||
) -> None:
|
||||
"""Write audio data to a file.
|
||||
|
||||
Args:
|
||||
output_path: Path to the output audio file
|
||||
audio_data: Audio data as numpy array (int16)
|
||||
sample_rate: Sample rate in Hz
|
||||
verbose: If True, print status information
|
||||
|
||||
Raises:
|
||||
ValueError: If output file extension is not supported
|
||||
"""
|
||||
# Validate output file extension
|
||||
valid_extensions = [".wav", ".flac", ".ogg"]
|
||||
output_ext = output_path[output_path.rfind(".") :].lower() if "." in output_path else ""
|
||||
|
||||
if output_ext not in valid_extensions:
|
||||
raise ValueError(
|
||||
f"Invalid output file extension: '{output_ext}'. "
|
||||
f"Supported formats: {', '.join(valid_extensions)}"
|
||||
)
|
||||
|
||||
if verbose:
|
||||
print(f"Saving audio to: {output_path}")
|
||||
print(f" - Format: {output_ext[1:].upper()}")
|
||||
print(f" - Samples: {len(audio_data)}")
|
||||
print(f" - Sample rate: {sample_rate} Hz")
|
||||
|
||||
# Write the audio file
|
||||
sf.write(output_path, audio_data, sample_rate)
|
||||
|
||||
if verbose:
|
||||
print(f"✓ Audio saved successfully")
|
||||
|
||||
|
||||
def calculate_audio_stats(audio_data: np.ndarray) -> dict:
|
||||
"""Calculate statistics for audio data.
|
||||
|
||||
Args:
|
||||
audio_data: Audio data as numpy array
|
||||
|
||||
Returns:
|
||||
Dictionary with audio statistics
|
||||
"""
|
||||
rms = np.sqrt(np.mean(audio_data.astype(np.float32) ** 2))
|
||||
return {
|
||||
"min": int(audio_data.min()),
|
||||
"max": int(audio_data.max()),
|
||||
"mean": float(audio_data.mean()),
|
||||
"std": float(audio_data.std()),
|
||||
"rms": float(rms),
|
||||
"samples": len(audio_data),
|
||||
}
|
||||
262
scripts/krisp/test_krisp_viva_filter_audiofile.py
Normal file
262
scripts/krisp/test_krisp_viva_filter_audiofile.py
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone script to test Krisp VIVA filter with real audio files.
|
||||
|
||||
This script processes audio files through Krisp VIVA filter (noise reduction) and saves the output,
|
||||
allowing you to compare the original and filtered audio.
|
||||
|
||||
Usage:
|
||||
python test_krisp_viva_filter_audiofile.py input.wav output.wav
|
||||
python test_krisp_viva_filter_audiofile.py input.wav output.wav --level 80
|
||||
|
||||
Requirements:
|
||||
pip install soundfile numpy pipecat-ai[krisp]
|
||||
Set KRISP_VIVA_FILTER_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
|
||||
from audio_file_utils import calculate_audio_stats, read_audio_file, write_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 filter
|
||||
try:
|
||||
from pipecat.audio.filters.krisp_viva_filter import KrispVivaFilter
|
||||
from pipecat.audio.krisp_instance import KRISP_SAMPLE_RATES
|
||||
except ImportError as e:
|
||||
print(f"Error: Could not import Krisp VIVA filter: {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 model path is set and exists."""
|
||||
env_var = "KRISP_VIVA_FILTER_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 process_audio_file(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
noise_suppression_level: int = 100,
|
||||
frame_duration_ms: int = 10,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Process an audio file through Krisp VIVA filter.
|
||||
|
||||
Args:
|
||||
input_path: Path to input audio file
|
||||
output_path: Path to save filtered audio
|
||||
noise_suppression_level: Noise suppression level (0-100)
|
||||
frame_duration_ms: Frame duration in milliseconds (for chunking input)
|
||||
verbose: Show detailed processing information
|
||||
"""
|
||||
# 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 filter:")
|
||||
print(f" - Model path: {model_path}")
|
||||
print(f" - Noise suppression level: {noise_suppression_level}")
|
||||
print(f" - Frame duration: {frame_duration_ms}ms (processing chunk size)")
|
||||
print(f" - Sample rate: {sample_rate}Hz")
|
||||
|
||||
# Create filter instance and measure preload time
|
||||
print("\nInitializing filter (preloading model)...")
|
||||
preload_start_time = time.time()
|
||||
filter_obj = KrispVivaFilter(
|
||||
model_path=model_path,
|
||||
noise_suppression_level=noise_suppression_level,
|
||||
)
|
||||
preload_duration = time.time() - preload_start_time
|
||||
print(f"Model preloaded in {preload_duration * 1000:.2f}ms")
|
||||
|
||||
try:
|
||||
# Measure filter start time
|
||||
print("\nStarting filter...")
|
||||
start_time = time.time()
|
||||
await filter_obj.start(sample_rate)
|
||||
start_duration = time.time() - start_time
|
||||
print(f"Filter started in {start_duration * 1000:.2f}ms")
|
||||
|
||||
print("\nProcessing audio...")
|
||||
filtered_samples = []
|
||||
total_frames = 0
|
||||
|
||||
# Use chunk size matching filter frame duration
|
||||
chunk_size = int(sample_rate * frame_duration_ms / 1000)
|
||||
print(f" - Chunk size: {chunk_size} samples ({frame_duration_ms}ms)")
|
||||
|
||||
if verbose:
|
||||
print(f" - Processing {len(audio_data)} samples in chunks of {chunk_size}")
|
||||
|
||||
for i in range(0, len(audio_data), chunk_size):
|
||||
chunk = audio_data[i : i + chunk_size]
|
||||
|
||||
if len(chunk) == 0:
|
||||
break
|
||||
|
||||
# Skip incomplete chunks
|
||||
if len(chunk) < chunk_size:
|
||||
if verbose:
|
||||
print(f"\n Skipping incomplete final chunk: {len(chunk)} samples")
|
||||
break
|
||||
|
||||
# Filter the chunk
|
||||
filtered_chunk_bytes = await filter_obj.filter(chunk.tobytes())
|
||||
|
||||
# Collect filtered samples
|
||||
if filtered_chunk_bytes:
|
||||
filtered_chunk = np.frombuffer(filtered_chunk_bytes, dtype=np.int16)
|
||||
filtered_samples.append(filtered_chunk)
|
||||
total_frames += 1
|
||||
|
||||
if verbose and total_frames <= 3:
|
||||
print(
|
||||
f" Frame {total_frames}: {len(chunk)} -> {len(filtered_chunk)} samples"
|
||||
)
|
||||
|
||||
# Progress indicator
|
||||
if i % (chunk_size * 50) == 0:
|
||||
progress = (i / len(audio_data)) * 100
|
||||
print(f" Progress: {progress:.1f}%", end="\r")
|
||||
|
||||
print(f" Progress: 100.0% - Processed {total_frames} frames")
|
||||
|
||||
# Concatenate all filtered samples
|
||||
if filtered_samples:
|
||||
filtered_audio = np.concatenate(filtered_samples)
|
||||
print(f"\nFiltered audio: {len(filtered_audio)} samples")
|
||||
|
||||
# Save the filtered audio
|
||||
write_audio_file(output_path, filtered_audio, sample_rate, verbose=True)
|
||||
|
||||
# Calculate statistics
|
||||
original_stats = calculate_audio_stats(audio_data)
|
||||
filtered_stats = calculate_audio_stats(filtered_audio)
|
||||
|
||||
print("\nAudio Statistics:")
|
||||
print(f" Original RMS: {original_stats['rms']:.2f}")
|
||||
print(f" Filtered RMS: {filtered_stats['rms']:.2f}")
|
||||
print(f" RMS Ratio: {filtered_stats['rms'] / original_stats['rms']:.2f}")
|
||||
|
||||
if filtered_stats["rms"] < 0.01:
|
||||
print("\n ⚠️ WARNING: Filtered audio is very quiet or silent!")
|
||||
print(" This may indicate a processing issue.")
|
||||
|
||||
print("\n✅ Processing complete!")
|
||||
print(f" Original: {input_path}")
|
||||
print(f" Filtered: {output_path}")
|
||||
print("\nListen to both files to compare the results.")
|
||||
|
||||
else:
|
||||
print("Error: No filtered audio produced")
|
||||
sys.exit(1)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await filter_obj.stop()
|
||||
print("Filter stopped.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Krisp VIVA filter with real audio files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python test_krisp_viva_audiofile.py noisy_input.wav clean_output.wav
|
||||
python test_krisp_viva_audiofile.py input.wav output.wav --level 80
|
||||
|
||||
Supported audio formats: WAV, FLAC, OGG, etc. (via soundfile)
|
||||
Supported sample rates: 8000, 16000, 24000, 32000, 44100, 48000 Hz
|
||||
|
||||
Note: Set KRISP_VIVA_FILTER_MODEL_PATH environment variable to point to your .kef model file
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument("input", help="Input audio file path")
|
||||
parser.add_argument("output", help="Output audio file path")
|
||||
parser.add_argument(
|
||||
"--level",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Noise suppression level (0-100, default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-duration",
|
||||
type=int,
|
||||
default=10,
|
||||
choices=[10, 15, 20, 30, 32],
|
||||
help="Frame duration in milliseconds (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show detailed processing information",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# Create output directory if needed
|
||||
output_dir = os.path.dirname(args.output)
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
# Process the audio
|
||||
asyncio.run(
|
||||
process_audio_file(
|
||||
args.input,
|
||||
args.output,
|
||||
noise_suppression_level=args.level,
|
||||
frame_duration_ms=args.frame_duration,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
333
scripts/krisp/test_krisp_viva_turn_audiofile.py
Normal file
333
scripts/krisp/test_krisp_viva_turn_audiofile.py
Normal file
@@ -0,0 +1,333 @@
|
||||
#!/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
|
||||
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:
|
||||
"""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()
|
||||
Reference in New Issue
Block a user