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:
83
crates/audio/src/wav.rs
Normal file
83
crates/audio/src/wav.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user