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:
Garegin Harutyunyan
2026-01-09 17:15:08 +04:00
committed by GitHub
parent 72a44c2fcd
commit 16819a5caa
10 changed files with 2369 additions and 99 deletions

View 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),
}

View 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()

View 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()