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:
@@ -6,3 +6,21 @@ description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (sy
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kon-core = { path = "../core" }
|
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"] }
|
||||||
|
|||||||
75
crates/audio/src/capture.rs
Normal file
75
crates/audio/src/capture.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/audio/src/concurrency.rs
Normal file
19
crates/audio/src/concurrency.rs
Normal 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
104
crates/audio/src/decode.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -1,2 +1,13 @@
|
|||||||
// kon-audio: Microphone capture, voice activity detection, resampling,
|
pub mod capture;
|
||||||
// file decoding, and WAV I/O.
|
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};
|
||||||
|
|||||||
105
crates/audio/src/resample.rs
Normal file
105
crates/audio/src/resample.rs
Normal 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
50
crates/audio/src/vad.rs
Normal 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
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