agent: foundation — import legacy codebase from Obsidian vault
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
crates/audio/Cargo.toml
Normal file
27
crates/audio/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "kon-audio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Audio capture (cpal), VAD, resampling (rubato), file decoding (symphonia), WAV I/O (hound) for Kon"
|
||||
|
||||
[dependencies]
|
||||
kon-core = { path = "../core" }
|
||||
|
||||
# Microphone capture
|
||||
cpal = "0.17"
|
||||
|
||||
# Voice activity detection — deferred until ort version conflict between
|
||||
# VAD crates (ort rc.10) and transcribe-rs (ort rc.12) is resolved upstream.
|
||||
# silero-vad-rust = { version = "6", default-features = false }
|
||||
|
||||
# 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))
|
||||
}
|
||||
13
crates/audio/src/lib.rs
Normal file
13
crates/audio/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
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};
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
35
crates/audio/src/vad.rs
Normal file
35
crates/audio/src/vad.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Voice Activity Detection — stubbed.
|
||||
//
|
||||
// Both `voice_activity_detector` and `silero-vad-rust` pin ort 2.0.0-rc.10
|
||||
// which conflicts with transcribe-rs requiring ort 2.0.0-rc.12.
|
||||
// When the ort ecosystem aligns (likely at 2.0.0 stable), add Silero VAD here.
|
||||
//
|
||||
// For now, all audio is treated as speech. This matches v0.2 behaviour
|
||||
// (no VAD) and doesn't affect core functionality.
|
||||
|
||||
use kon_core::constants::VAD_SPEECH_THRESHOLD;
|
||||
|
||||
/// Stub speech detector. Treats all audio as speech.
|
||||
#[derive(Default)]
|
||||
pub struct SpeechDetector {
|
||||
threshold: f64,
|
||||
}
|
||||
|
||||
impl SpeechDetector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
threshold: VAD_SPEECH_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Always returns true (no VAD filtering until ort conflict resolved).
|
||||
pub fn is_speech(&self, _samples: &[f32]) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn threshold(&self) -> f64 {
|
||||
self.threshold
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {}
|
||||
}
|
||||
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