- 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>
84 lines
2.6 KiB
Rust
84 lines
2.6 KiB
Rust
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();
|
|
}
|
|
}
|