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:
jake
2026-03-21 10:28:24 +00:00
parent 499938591f
commit e13d7d82cc
114 changed files with 17387 additions and 0 deletions

27
crates/audio/Cargo.toml Normal file
View 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"] }

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

13
crates/audio/src/lib.rs Normal file
View 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};

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"
);
}
}

35
crates/audio/src/vad.rs Normal file
View 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
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();
}
}