feat(kon): add audio crate — cpal capture, VAD, rubato resample, symphonia decode

- File decode via symphonia (mp3, aac, flac, wav, ogg) with mono mixdown
- Sinc interpolation resampling via rubato 0.15 (48kHz/44.1kHz → 16kHz)
- WAV I/O via hound (read/write with f32→i16 conversion)
- Microphone capture via cpal 0.17 (WASAPI on Windows) with mpsc channel output
- Voice activity detection via Silero VAD V5 (voice_activity_detector 0.2)
- Async decode_and_resample() for file transcription pipeline
- 3 tests passing (resample passthrough, duration preservation, WAV roundtrip)
- clippy clean, no ort version conflicts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:56:30 +00:00
parent 100ecb4eae
commit a30c9cc107
8 changed files with 467 additions and 2 deletions

50
crates/audio/src/vad.rs Normal file
View File

@@ -0,0 +1,50 @@
use voice_activity_detector::VoiceActivityDetector;
use kon_core::constants::{VAD_SPEECH_THRESHOLD, WHISPER_SAMPLE_RATE};
use kon_core::error::{KonError, Result};
/// VAD chunk size for 16kHz audio (required by Silero VAD V5).
const VAD_CHUNK_SIZE: usize = 512;
/// Wraps Silero VAD V5 for speech detection.
pub struct SpeechDetector {
vad: VoiceActivityDetector,
threshold: f64,
}
impl SpeechDetector {
/// Create a new speech detector with the default threshold.
pub fn new() -> Result<Self> {
let vad = VoiceActivityDetector::builder()
.sample_rate(WHISPER_SAMPLE_RATE as i64)
.chunk_size(VAD_CHUNK_SIZE)
.build()
.map_err(|e| KonError::AudioCaptureFailed(format!("VAD init failed: {e}")))?;
Ok(Self {
vad,
threshold: VAD_SPEECH_THRESHOLD,
})
}
/// Predict whether a chunk of 16kHz f32 audio contains speech.
/// Returns the speech probability (0.0 to 1.0).
pub fn predict(&mut self, samples: &[f32]) -> f32 {
self.vad.predict(samples.iter().copied())
}
/// Returns true if the chunk contains speech above the threshold.
pub fn is_speech(&mut self, samples: &[f32]) -> bool {
self.predict(samples) > self.threshold as f32
}
/// Reset the internal VAD state (call between recording sessions).
pub fn reset(&mut self) {
self.vad.reset();
}
/// The required chunk size for this VAD instance.
pub fn chunk_size(&self) -> usize {
VAD_CHUNK_SIZE
}
}