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

View File

@@ -6,3 +6,21 @@ description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (sy
[dependencies]
kon-core = { path = "../core" }
# Microphone capture
cpal = "0.17"
# Voice activity detection (Silero VAD V5)
voice_activity_detector = "0.2"
# High-quality resampling (sinc interpolation)
rubato = "0.15"
# WAV file I/O
hound = "3.5"
# Audio file decoding (mp3, aac, flac, wav, ogg, etc.)
symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "pcm", "vorbis", "wav", "ogg", "isomp4"] }
# Async runtime for threading
tokio = { version = "1", features = ["rt", "sync"] }

View File

@@ -0,0 +1,75 @@
use std::sync::mpsc;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use kon_core::error::{KonError, Result};
/// A chunk of captured audio from the microphone.
pub struct AudioChunk {
pub samples: Vec<f32>,
pub sample_rate: u32,
pub channels: u16,
}
/// Manages microphone capture via cpal.
/// Call `start()` to begin capturing, which returns a receiver for audio chunks.
/// Call `stop()` to end the stream.
pub struct MicrophoneCapture {
stream: Option<cpal::Stream>,
}
impl MicrophoneCapture {
/// Start capturing audio from the default input device.
/// Returns a receiver that yields AudioChunks as they arrive.
pub fn start() -> Result<(Self, mpsc::Receiver<AudioChunk>)> {
let host = cpal::default_host();
let device = host.default_input_device().ok_or_else(|| {
KonError::AudioCaptureFailed("No input device found".into())
})?;
let config = device.default_input_config().map_err(|e| {
KonError::AudioCaptureFailed(format!("No input config: {e}"))
})?;
let sample_rate = config.sample_rate();
let channels = config.channels() as u16;
let (tx, rx) = mpsc::channel::<AudioChunk>();
let stream = device
.build_input_stream(
&config.into(),
move |data: &[f32], _info: &cpal::InputCallbackInfo| {
let _ = tx.send(AudioChunk {
samples: data.to_vec(),
sample_rate,
channels,
});
},
|err| eprintln!("audio capture error: {err}"),
None,
)
.map_err(|e| {
KonError::AudioCaptureFailed(format!("Build stream failed: {e}"))
})?;
stream.play().map_err(|e| {
KonError::AudioCaptureFailed(format!("Stream play failed: {e}"))
})?;
Ok((Self { stream: Some(stream) }, rx))
}
/// Stop capturing audio.
pub fn stop(&mut self) {
if let Some(stream) = self.stream.take() {
let _ = stream.pause();
}
}
}
impl Drop for MicrophoneCapture {
fn drop(&mut self) {
self.stop();
}
}

View File

@@ -0,0 +1,19 @@
use std::path::Path;
use kon_core::error::Result;
use kon_core::types::AudioSamples;
use crate::decode::decode_audio_file;
use crate::resample::resample_to_16khz;
/// Decode and resample an audio file on a blocking thread.
/// Returns 16kHz mono AudioSamples ready for transcription.
pub async fn decode_and_resample(path: &Path) -> Result<AudioSamples> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let audio = decode_audio_file(&path)?;
resample_to_16khz(&audio)
})
.await
.map_err(|e| kon_core::error::KonError::AudioDecodeFailed(format!("Task join error: {e}")))?
}

104
crates/audio/src/decode.rs Normal file
View File

@@ -0,0 +1,104 @@
use std::fs::File;
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use kon_core::error::{KonError, Result};
use kon_core::types::AudioSamples;
/// Decode an audio file to mono f32 PCM samples.
/// Supports all formats symphonia handles: mp3, aac, flac, wav, ogg, etc.
pub fn decode_audio_file(path: &Path) -> Result<AudioSamples> {
let file = File::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("Cannot open file: {e}")))?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.map_err(|e| KonError::AudioDecodeFailed(format!("Unsupported format: {e}")))?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or_else(|| KonError::AudioDecodeFailed("No audio track found".into()))?;
let sample_rate = track
.codec_params
.sample_rate
.ok_or_else(|| KonError::AudioDecodeFailed("Unknown sample rate".into()))?;
if sample_rate == 0 {
return Err(KonError::AudioDecodeFailed("Invalid sample rate: 0".into()));
}
let track_id = track.id;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| KonError::AudioDecodeFailed(format!("Codec error: {e}")))?;
let mut samples: Vec<f32> = Vec::new();
let mut decode_errors = 0u32;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(symphonia::core::errors::Error::IoError(ref e))
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
{
break;
}
Err(symphonia::core::errors::Error::ResetRequired) => break,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(_) => {
decode_errors += 1;
continue;
}
};
let spec = *decoded.spec();
let channels = spec.channels.count();
let mut sample_buf =
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
sample_buf.copy_interleaved_ref(decoded);
let buf = sample_buf.samples();
if channels == 1 {
samples.extend_from_slice(buf);
} else {
for chunk in buf.chunks(channels) {
let sum: f32 = chunk.iter().sum();
samples.push(sum / channels as f32);
}
}
}
if samples.is_empty() {
if decode_errors > 0 {
return Err(KonError::AudioDecodeFailed(format!(
"No audio decoded ({decode_errors} packets failed — file may be corrupt)"
)));
}
return Err(KonError::AudioDecodeFailed("No audio data decoded".into()));
}
Ok(AudioSamples::new(samples, sample_rate, 1))
}

View File

@@ -1,2 +1,13 @@
// kon-audio: Microphone capture, voice activity detection, resampling,
// file decoding, and WAV I/O.
pub mod capture;
pub mod concurrency;
pub mod decode;
pub mod resample;
pub mod vad;
pub mod wav;
pub use capture::{AudioChunk, MicrophoneCapture};
pub use concurrency::decode_and_resample;
pub use decode::decode_audio_file;
pub use resample::resample_to_16khz;
pub use vad::SpeechDetector;
pub use wav::{read_wav, write_wav};

View File

@@ -0,0 +1,105 @@
use rubato::{SincFixedIn, SincInterpolationParameters, SincInterpolationType, Resampler, WindowFunction};
use kon_core::constants::WHISPER_SAMPLE_RATE;
use kon_core::error::{KonError, Result};
use kon_core::types::AudioSamples;
/// Resample audio to 16kHz mono using sinc interpolation (rubato).
/// Returns a new AudioSamples at the target sample rate.
pub fn resample_to_16khz(audio: &AudioSamples) -> Result<AudioSamples> {
let from_rate = audio.sample_rate();
let target_rate = WHISPER_SAMPLE_RATE;
if from_rate == target_rate {
return Ok(AudioSamples::mono_16khz(audio.samples().to_vec()));
}
if from_rate == 0 {
return Err(KonError::AudioDecodeFailed(
"Cannot resample: source rate is 0".into(),
));
}
let ratio = target_rate as f64 / from_rate as f64;
let chunk_size = 1024;
let params = SincInterpolationParameters {
sinc_len: 256,
f_cutoff: 0.95,
oversampling_factor: 128,
interpolation: SincInterpolationType::Cubic,
window: WindowFunction::Blackman,
};
let mut resampler = SincFixedIn::<f32>::new(
ratio,
1.1,
params,
chunk_size,
1, // mono
)
.map_err(|e| {
KonError::AudioDecodeFailed(format!("Resampler init failed: {e}"))
})?;
let samples = audio.samples();
let mut output_samples: Vec<f32> = Vec::new();
let mut offset = 0;
while offset < samples.len() {
let end = (offset + chunk_size).min(samples.len());
let mut chunk = samples[offset..end].to_vec();
if chunk.len() < chunk_size {
chunk.resize(chunk_size, 0.0);
}
let input = vec![chunk];
let result = resampler.process(&input, None).map_err(|e| {
KonError::AudioDecodeFailed(format!("Resample failed: {e}"))
})?;
if !result.is_empty() && !result[0].is_empty() {
output_samples.extend_from_slice(&result[0]);
}
offset += chunk_size;
}
// Trim to expected length (padding may have added extra samples)
let expected_len = (samples.len() as f64 * ratio) as usize;
output_samples.truncate(expected_len);
Ok(AudioSamples::mono_16khz(output_samples))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_passthrough_at_16khz() {
let input = AudioSamples::mono_16khz(vec![0.1, 0.2, 0.3]);
let output = resample_to_16khz(&input).unwrap();
assert_eq!(output.sample_rate(), 16000);
assert_eq!(output.samples().len(), 3);
}
#[test]
fn resample_preserves_approximate_duration() {
let rate = 48000;
let duration_secs = 1.0;
let num_samples = (rate as f64 * duration_secs) as usize;
let samples: Vec<f32> =
(0..num_samples).map(|i| (i as f32 * 0.001).sin()).collect();
let input = AudioSamples::new(samples, rate, 1);
let output = resample_to_16khz(&input).unwrap();
let output_duration = output.samples().len() as f64 / 16000.0;
assert!(
(output_duration - duration_secs).abs() < 0.1,
"Duration mismatch: expected ~{duration_secs}s, got {output_duration}s"
);
}
}

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
}
}

83
crates/audio/src/wav.rs Normal file
View File

@@ -0,0 +1,83 @@
use std::path::Path;
use kon_core::error::{KonError, Result};
use kon_core::types::AudioSamples;
/// Write f32 PCM samples to a 16-bit WAV file.
pub fn write_wav(path: &Path, audio: &AudioSamples) -> Result<()> {
let spec = hound::WavSpec {
channels: audio.channels(),
sample_rate: audio.sample_rate(),
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(path, spec)
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
for &sample in audio.samples() {
let clamped = sample.clamp(-1.0, 1.0);
let int_sample = (clamped * i16::MAX as f32) as i16;
writer
.write_sample(int_sample)
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV write failed: {e}"))))?;
}
writer
.finalize()
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV finalize failed: {e}"))))?;
Ok(())
}
/// Read a WAV file to f32 PCM AudioSamples.
pub fn read_wav(path: &Path) -> Result<AudioSamples> {
let reader = hound::WavReader::open(path)
.map_err(|e| KonError::AudioDecodeFailed(format!("WAV open failed: {e}")))?;
let spec = reader.spec();
let sample_rate = spec.sample_rate;
let channels = spec.channels;
let samples: Vec<f32> = match spec.sample_format {
hound::SampleFormat::Int => reader
.into_samples::<i32>()
.filter_map(|s| s.ok())
.map(|s| s as f32 / (1 << (spec.bits_per_sample - 1)) as f32)
.collect(),
hound::SampleFormat::Float => reader
.into_samples::<f32>()
.filter_map(|s| s.ok())
.collect(),
};
Ok(AudioSamples::new(samples, sample_rate, channels))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wav_roundtrip() {
let temp_dir = std::env::temp_dir();
let path = temp_dir.join("kon_test_roundtrip.wav");
let original = AudioSamples::mono_16khz(vec![0.0, 0.5, -0.5, 0.25, -0.25]);
write_wav(&path, &original).unwrap();
let loaded = read_wav(&path).unwrap();
assert_eq!(loaded.sample_rate(), 16000);
assert_eq!(loaded.samples().len(), 5);
// 16-bit quantisation introduces small error
for (a, b) in original.samples().iter().zip(loaded.samples().iter()) {
assert!(
(a - b).abs() < 0.001,
"Sample mismatch: original={a}, loaded={b}"
);
}
std::fs::remove_file(&path).ok();
}
}