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

View File

@@ -0,0 +1,9 @@
[package]
name = "kon-ai-formatting"
version = "0.1.0"
edition = "2021"
description = "Text post-processing pipeline: filler removal, British English conversion, formatting for Kon"
[dependencies]
kon-core = { path = "../core" }
regex-lite = "0.1"

View File

@@ -0,0 +1,6 @@
mod llm_client;
pub mod pipeline;
pub mod rule_based;
pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions};
pub use rule_based::{format_text, is_hallucination, remove_fillers, to_british_english};

View File

@@ -0,0 +1,5 @@
//! Placeholder for future LLM sidecar integration (e.g., mistral.rs for smart formatting).
//!
//! When implemented, this module will expose a client that sends transcription
//! segments to a local LLM for context-aware punctuation, paragraph splitting,
//! and stylistic cleanup beyond what the rule-based pipeline can achieve.

View File

@@ -0,0 +1,119 @@
use kon_core::constants::SMART_PARAGRAPH_GAP_SECS;
use kon_core::types::Segment;
use crate::rule_based;
/// Post-processing options for a transcription pipeline run.
pub struct PostProcessOptions {
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
pub format_mode: FormatMode,
}
/// How aggressively to format the transcript text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatMode {
Raw,
Clean,
Smart,
}
impl FormatMode {
pub fn parse(s: &str) -> Self {
match s {
"Clean" => Self::Clean,
"Smart" => Self::Smart,
_ => Self::Raw,
}
}
}
/// Apply all post-processing steps to a list of segments.
/// Modifies segments in place. Composed from individual pure functions.
pub fn post_process_segments(segments: &mut Vec<Segment>, options: &PostProcessOptions) {
if options.anti_hallucination {
segments.retain(|seg| !rule_based::is_hallucination(&seg.text));
}
for seg in segments.iter_mut() {
if options.remove_fillers {
seg.text = rule_based::remove_fillers(&seg.text);
}
if options.british_english {
seg.text = rule_based::to_british_english(&seg.text);
}
if options.format_mode != FormatMode::Raw {
seg.text = rule_based::format_text(&seg.text);
}
}
if options.format_mode == FormatMode::Smart && segments.len() > 1 {
for i in (1..segments.len()).rev() {
let gap = segments[i].start - segments[i - 1].end;
if gap > SMART_PARAGRAPH_GAP_SECS {
segments[i].text = format!("\n\n{}", segments[i].text.trim_start());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_segments() -> Vec<Segment> {
vec![
Segment {
start: 0.0,
end: 1.0,
text: "um hello world".into(),
},
Segment {
start: 1.0,
end: 2.0,
text: "[blank_audio]".into(),
},
Segment {
start: 5.0,
end: 6.0,
text: "organize the color scheme".into(),
},
]
}
#[test]
fn post_process_applies_all_filters() {
let mut segments = make_segments();
let options = PostProcessOptions {
remove_fillers: true,
british_english: true,
anti_hallucination: true,
format_mode: FormatMode::Clean,
};
post_process_segments(&mut segments, &options);
assert_eq!(segments.len(), 2);
let lower0 = segments[0].text.to_lowercase();
let lower1 = segments[1].text.to_lowercase();
assert!(!lower0.contains("um"));
assert!(lower1.contains("organise"));
assert!(lower1.contains("colour"));
}
#[test]
fn post_process_adds_paragraph_breaks_on_long_pauses() {
let mut segments = make_segments();
let options = PostProcessOptions {
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: FormatMode::Smart,
};
post_process_segments(&mut segments, &options);
assert!(segments[2].text.starts_with("\n\n"));
}
}

View File

@@ -0,0 +1,291 @@
use std::sync::LazyLock;
/// Compiled filler word regexes (built once, reused across calls).
/// Uses \b word boundaries instead of lookbehinds (regex-lite does not
/// support lookaround assertions).
static FILLER_REGEXES: LazyLock<Vec<regex_lite::Regex>> = LazyLock::new(|| {
let fillers = [
"um",
"uh",
"er",
"ah",
"like",
"you know",
"sort of",
"kind of",
"I mean",
"basically",
"actually",
"literally",
];
fillers
.iter()
.filter_map(|filler| {
let escaped = regex_lite::escape(filler);
let pattern = format!(r"(?i)\b{escaped}\b[,.]?\s*");
regex_lite::Regex::new(&pattern).ok()
})
.collect()
});
/// Remove common filler words from transcription text (case-insensitive).
pub fn remove_fillers(text: &str) -> String {
let mut result = text.to_string();
for re in FILLER_REGEXES.iter() {
result = re.replace_all(&result, " ").to_string();
}
// Collapse runs of whitespace in a single pass.
let mut collapsed = String::with_capacity(result.len());
let mut prev_space = false;
for ch in result.chars() {
if ch == ' ' {
if !prev_space {
collapsed.push(' ');
}
prev_space = true;
} else {
prev_space = false;
collapsed.push(ch);
}
}
collapsed.trim().to_string()
}
/// Replacement pairs for American to British English conversion.
///
/// All entries are plain base words (no regex metacharacters). The
/// `to_british_english` function wraps every entry with `\b` word
/// boundaries and optional suffix matching automatically.
static BRITISH_REPLACEMENTS: &[(&str, &str)] = &[
// -ize → -ise (and inflected forms)
("organize", "organise"),
("recognize", "recognise"),
("realize", "realise"),
("analyze", "analyse"),
("apologize", "apologise"),
("authorize", "authorise"),
("categorize", "categorise"),
("characterize", "characterise"),
("customize", "customise"),
("digitize", "digitise"),
("emphasize", "emphasise"),
("finalize", "finalise"),
("generalize", "generalise"),
("harmonize", "harmonise"),
("initialize", "initialise"),
("maximize", "maximise"),
("minimize", "minimise"),
("modernize", "modernise"),
("normalize", "normalise"),
("optimize", "optimise"),
("prioritize", "prioritise"),
("revolutionize", "revolutionise"),
("specialize", "specialise"),
("standardize", "standardise"),
("summarize", "summarise"),
("utilize", "utilise"),
// -or → -our
("color", "colour"),
("favor", "favour"),
("honor", "honour"),
("humor", "humour"),
("labor", "labour"),
("neighbor", "neighbour"),
("behavior", "behaviour"),
// -er → -re
("center", "centre"),
("fiber", "fibre"),
("liter", "litre"),
("meter", "metre"),
("theater", "theatre"),
// -ense → -ence
("defense", "defence"),
("offense", "offence"),
// Other
("catalog", "catalogue"),
("dialog", "dialogue"),
];
/// Convert American English spelling to British English (word-boundary aware).
pub fn to_british_english(text: &str) -> String {
let mut result = text.to_string();
for (us, uk) in BRITISH_REPLACEMENTS {
// Every entry in BRITISH_REPLACEMENTS is a plain ASCII base word.
// We wrap it with \b boundaries and optional suffix matching here.
let pattern = format!("(?i)\\b{}(?:d|s|r|rs)?\\b", regex_lite::escape(us));
if let Ok(re) = regex_lite::Regex::new(&pattern) {
result = re
.replace_all(&result, |caps: &regex_lite::Captures| {
let Some(m) = caps.get(0) else {
return String::new();
};
let matched = m.as_str();
let base_len = us.len();
// SAFETY: byte indexing is correct here because both
// the US base word and the suffix characters (d, s, r)
// are guaranteed ASCII by the BRITISH_REPLACEMENTS table.
debug_assert!(us.is_ascii(), "BRITISH_REPLACEMENTS entries must be ASCII");
debug_assert!(matched.is_ascii(), "matched text expected to be ASCII");
let suffix = if matched.len() > base_len {
&matched[base_len..]
} else {
""
};
let Some(first_char) = matched.chars().next() else {
return String::new();
};
if first_char.is_uppercase() {
let mut chars = uk.chars();
let Some(first_uk) = chars.next() else {
return String::new();
};
let upper_first: String = first_uk.to_uppercase().collect();
format!("{}{}{}", upper_first, chars.collect::<String>(), suffix)
} else {
format!("{uk}{suffix}")
}
})
.to_string();
}
}
result
}
/// Basic formatting: capitalise sentences, fix spacing, clean punctuation.
pub fn format_text(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let mut result = String::with_capacity(text.len());
let mut capitalise_next = true;
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == ' ' && i + 1 < chars.len() && chars[i + 1] == ' ' {
i += 1;
continue;
}
if capitalise_next && c.is_alphabetic() {
result.extend(c.to_uppercase());
capitalise_next = false;
} else {
result.push(c);
}
if c == '.' || c == '!' || c == '?' {
capitalise_next = true;
}
if c == '\n' {
capitalise_next = true;
}
i += 1;
}
result
}
/// Known hallucination markers that should be filtered from transcriptions.
static HALLUCINATION_MARKERS: &[&str] = &["[blank_audio]", "[music]", "[silence]"];
static AUTO_THANKS_PHRASES: &[&str] = &["thank you.", "thanks.", "you.", "thank you for watching."];
/// Returns true if a segment's text looks like a hallucination.
pub fn is_hallucination(text: &str) -> bool {
let trimmed = text.trim().to_lowercase();
if trimmed.is_empty() {
return true;
}
for marker in HALLUCINATION_MARKERS {
if trimmed.contains(marker) {
return true;
}
}
if trimmed.len() < 15 {
for phrase in AUTO_THANKS_PHRASES {
if trimmed == *phrase {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remove_fillers_strips_um_and_uh() {
let input = "So um I was thinking uh about this";
let result = remove_fillers(input);
assert!(!result.contains("um"));
assert!(!result.contains("uh"));
}
#[test]
fn remove_fillers_preserves_legitimate_words() {
let input = "The umbrella was actually useful";
let result = remove_fillers(input);
assert!(result.contains("umbrella"));
assert!(result.contains("useful"));
}
#[test]
fn to_british_english_converts_ize_to_ise() {
assert!(to_british_english("organize").contains("organise"));
assert!(to_british_english("realize").contains("realise"));
}
#[test]
fn to_british_english_preserves_case() {
let result = to_british_english("Organize the files");
assert!(result.starts_with("Organise"));
}
#[test]
fn to_british_english_handles_colour() {
assert!(to_british_english("the color is red").contains("colour"));
}
#[test]
fn format_text_capitalises_after_full_stops() {
let result = format_text("hello world. this is a test");
assert!(result.starts_with('H'));
assert!(result.contains(". T"));
}
#[test]
fn format_text_handles_empty_string() {
assert_eq!(format_text(""), "");
}
#[test]
fn is_hallucination_detects_blank_audio() {
assert!(is_hallucination("[blank_audio]"));
assert!(is_hallucination(" [music] "));
}
#[test]
fn is_hallucination_detects_auto_thanks() {
assert!(is_hallucination("Thank you."));
assert!(is_hallucination("thanks."));
}
#[test]
fn is_hallucination_allows_real_text() {
assert!(!is_hallucination("The meeting is at three o'clock."));
}
}

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

View File

@@ -0,0 +1,8 @@
[package]
name = "kon-cloud-providers"
version = "0.1.0"
edition = "2021"
description = "BYOK cloud STT provider stubs and API key storage for Kon"
[dependencies]
kon-core = { path = "../core" }

View File

@@ -0,0 +1,29 @@
/// Store an API key in the OS keychain.
///
/// Stub implementation using environment variables until the `keyring` crate is
/// added. Keys are only held in-process and lost on exit.
///
/// # Safety note
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
/// thread-safe — mutating the environment while other threads read it is
/// undefined behaviour. This is acceptable during single-threaded app init
/// but must not be called from async/multi-threaded contexts.
///
/// TODO: Replace with the `keyring` crate (or platform-native credential
/// storage) so keys persist across sessions and are accessed safely.
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
pub fn store_api_key(provider: &str, key: &str) {
// SAFETY: Only safe when called from a single-threaded context (e.g. app
// initialisation). See doc comment above.
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
}
/// Retrieve an API key from the OS keychain.
///
/// Stub implementation using environment variables until the `keyring` crate is
/// added. Returns `None` if no key has been stored this session.
///
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
pub fn retrieve_api_key(provider: &str) -> Option<String> {
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok()
}

View File

@@ -0,0 +1,3 @@
pub mod keystore;
pub use keystore::{retrieve_api_key, store_api_key};

12
crates/core/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "kon-core"
version = "0.1.0"
edition = "2021"
description = "Core types, constants, traits, hardware detection, and model registry for Kon"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
sysinfo = "0.35"
async-trait = "0.1"

View File

@@ -0,0 +1,49 @@
/// Audio pipeline constants.
pub const WHISPER_SAMPLE_RATE: u32 = 16_000;
pub const WHISPER_CHANNELS: u16 = 1;
/// Parakeet mel spectrogram constants.
pub const PARAKEET_N_FFT: usize = 512;
pub const PARAKEET_HOP_LENGTH: usize = 160;
pub const PARAKEET_WIN_LENGTH: usize = 400;
pub const PARAKEET_N_MELS: usize = 80;
pub const PARAKEET_PRE_EMPHASIS: f32 = 0.97;
pub const PARAKEET_BLANK_TOKEN: usize = 1024;
pub const PARAKEET_LOG_GUARD: f32 = 5.960_464_5e-8; // 2^-24
/// Chunk timing for live transcription.
pub const CHUNK_INTERVAL_MS: u64 = 3000;
pub const MIN_CHUNK_SAMPLES: usize = 8000;
/// Post-processing thresholds.
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
/// Thread count for inference. Leaves headroom for the UI thread.
pub const MIN_INFERENCE_THREADS: usize = 4;
/// History limits.
pub const HISTORY_MAX_ENTRIES: usize = 100;
/// RAM thresholds for model recommendations (in GB).
pub const RAM_MINIMUM_FOR_LOCAL_STT: f64 = 2.0;
pub const RAM_THRESHOLD_LIGHTWEIGHT: f64 = 4.0;
pub const RAM_THRESHOLD_STANDARD: f64 = 8.0;
pub const RAM_THRESHOLD_COMFORTABLE: f64 = 16.0;
/// VAD configuration defaults.
pub const VAD_SPEECH_THRESHOLD: f64 = 0.5;
pub const VAD_MIN_SPEECH_DURATION_MS: u32 = 250;
pub const VAD_MAX_SPEECH_DURATION_S: u32 = 30;
pub const VAD_MIN_SILENCE_DURATION_MS: u32 = 300;
pub const VAD_SPEECH_PAD_MS: u32 = 100;
/// Model download chunk size for progress reporting.
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
/// Inference thread count based on available parallelism.
pub fn inference_thread_count() -> usize {
std::thread::available_parallelism()
.map(|p| p.get().saturating_sub(1))
.unwrap_or(MIN_INFERENCE_THREADS)
.max(MIN_INFERENCE_THREADS)
}

60
crates/core/src/error.rs Normal file
View File

@@ -0,0 +1,60 @@
use std::path::PathBuf;
use serde::Serialize;
use crate::types::ModelId;
/// Structured error type for Kon.
///
/// Implements `Serialize` so errors can be sent to the frontend as
/// structured JSON rather than opaque strings.
#[derive(Debug, thiserror::Error, Serialize)]
pub enum KonError {
#[error("model not found: {0}")]
ModelNotFound(ModelId),
#[error("model not downloaded: {0}")]
ModelNotDownloaded(ModelId),
#[error("engine not loaded: call load_model first")]
EngineNotLoaded,
#[error("transcription failed: {0}")]
TranscriptionFailed(String),
#[error("audio decode failed: {0}")]
AudioDecodeFailed(String),
#[error("audio capture failed: {0}")]
AudioCaptureFailed(String),
#[error("model download failed: {0}")]
DownloadFailed(String),
#[error("file not found: {}", .0.display())]
FileNotFound(PathBuf),
#[error("storage error: {0}")]
StorageError(String),
#[error("io error: {0}")]
Io(
#[from]
#[serde(serialize_with = "serialize_io_error")]
std::io::Error,
),
#[error("{0}")]
Other(String),
}
/// Serialises `std::io::Error` as its display string, since it does
/// not implement `Serialize` natively.
fn serialize_io_error<S: serde::Serializer>(
err: &std::io::Error,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&err.to_string())
}
pub type Result<T> = std::result::Result<T, KonError>;

105
crates/core/src/hardware.rs Normal file
View File

@@ -0,0 +1,105 @@
use sysinfo::System;
use crate::types::Megabytes;
/// Detected system capabilities.
#[derive(Debug, Clone)]
pub struct SystemProfile {
pub ram: Megabytes,
pub cpu: CpuInfo,
pub gpu: Option<GpuInfo>,
pub os: Os,
}
#[derive(Debug, Clone)]
pub struct CpuInfo {
pub logical_processors: usize,
pub brand: String,
}
#[derive(Debug, Clone)]
pub struct GpuInfo {
pub vendor: GpuVendor,
pub vram: Megabytes,
pub acceleration: GpuAcceleration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpuVendor {
Nvidia,
Amd,
Intel,
Apple,
Unknown,
}
#[derive(Debug, Clone)]
pub struct GpuAcceleration {
pub cuda: bool,
pub metal: bool,
pub vulkan: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
Windows,
Linux,
MacOs,
Ios,
Android,
}
/// Probes RAM from a shared `System` instance.
fn probe_ram_from(sys: &System) -> Megabytes {
let total_bytes = sys.total_memory();
Megabytes(total_bytes / (1024 * 1024))
}
/// Probes CPU info from a shared `System` instance.
fn probe_cpu_from(sys: &System) -> CpuInfo {
CpuInfo {
logical_processors: sys.cpus().len(),
brand: sys
.cpus()
.first()
.map(|c| c.brand().to_string())
.unwrap_or_default(),
}
}
pub fn probe_gpu() -> Option<GpuInfo> {
// GPU detection via wgpu or platform-specific APIs.
// Placeholder: returns None until wgpu or nvml integration is added.
None
}
pub fn probe_os() -> Os {
#[cfg(target_os = "windows")]
return Os::Windows;
#[cfg(target_os = "linux")]
return Os::Linux;
#[cfg(target_os = "macos")]
return Os::MacOs;
#[cfg(target_os = "ios")]
return Os::Ios;
#[cfg(target_os = "android")]
return Os::Android;
// Fallback for unsupported targets — treat as Linux since
// most exotic/embedded targets are Unix-like.
#[allow(unreachable_code)]
Os::Linux
}
/// Composes the individual probes using a single `System` snapshot.
/// `System::new_all()` is expensive — calling it once rather than
/// per-probe avoids redundant OS queries.
pub fn probe_system() -> SystemProfile {
let sys = System::new_all();
SystemProfile {
ram: probe_ram_from(&sys),
cpu: probe_cpu_from(&sys),
gpu: probe_gpu(),
os: probe_os(),
}
}

13
crates/core/src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
pub mod constants;
pub mod error;
pub mod hardware;
pub mod model_registry;
pub mod providers;
pub mod recommendation;
pub mod types;
pub use error::{KonError, Result};
pub use types::{
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment,
Transcript, TranscriptMetadata, TranscriptionOptions,
};

View File

@@ -0,0 +1,166 @@
use std::sync::LazyLock;
use crate::types::{Megabytes, ModelId};
/// Which inference backend a model uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Engine {
Whisper,
Parakeet,
Moonshine,
}
/// Qualitative speed classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpeedTier {
Instant,
Fast,
Moderate,
Slow,
}
/// Qualitative accuracy classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccuracyTier {
Excellent,
Great,
Good,
}
/// Language support scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LanguageSupport {
EnglishOnly,
Multilingual(u16),
}
/// File required for a model download.
#[derive(Debug, Clone)]
pub struct ModelFile {
pub filename: &'static str,
pub url: &'static str,
pub size: Megabytes,
}
/// All metadata for a single downloadable model.
/// This is pure data — no scoring logic lives here.
#[derive(Debug, Clone)]
pub struct ModelEntry {
pub id: ModelId,
pub engine: Engine,
pub display_name: &'static str,
pub disk_size: Megabytes,
pub ram_required: Megabytes,
pub speed_tier: SpeedTier,
pub accuracy_tier: AccuracyTier,
pub languages: LanguageSupport,
pub files: Vec<ModelFile>,
pub description: &'static str,
}
static ALL_MODELS: LazyLock<Vec<ModelEntry>> = LazyLock::new(|| {
vec![
ModelEntry {
id: ModelId::new("parakeet-ctc-0.6b-int8"),
engine: Engine::Parakeet,
display_name: "Parakeet CTC 0.6B (int8)",
disk_size: Megabytes(613),
ram_required: Megabytes(600),
speed_tier: SpeedTier::Instant,
accuracy_tier: AccuracyTier::Great,
languages: LanguageSupport::EnglishOnly,
files: vec![
ModelFile {
filename: "encoder-model.onnx",
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx",
size: Megabytes(1),
},
ModelFile {
filename: "model_int8.onnx_data",
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/onnx/model_int8.onnx_data",
size: Megabytes(611),
},
ModelFile {
filename: "tokenizer.json",
url: "https://huggingface.co/onnx-community/parakeet-ctc-0.6b-ONNX/resolve/main/tokenizer.json",
size: Megabytes(1),
},
],
description: "Fastest local model — near-instant transcription",
},
ModelEntry {
id: ModelId::new("whisper-tiny-en"),
engine: Engine::Whisper,
display_name: "Whisper Tiny (English)",
disk_size: Megabytes(75),
ram_required: Megabytes(390),
speed_tier: SpeedTier::Fast,
accuracy_tier: AccuracyTier::Good,
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-tiny.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
size: Megabytes(75),
}],
description: "Bundled with app — works instantly",
},
ModelEntry {
id: ModelId::new("whisper-base-en"),
engine: Engine::Whisper,
display_name: "Whisper Base (English)",
disk_size: Megabytes(142),
ram_required: Megabytes(500),
speed_tier: SpeedTier::Fast,
accuracy_tier: AccuracyTier::Good,
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-base.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
size: Megabytes(142),
}],
description: "Good balance of speed and accuracy",
},
ModelEntry {
id: ModelId::new("whisper-small-en"),
engine: Engine::Whisper,
display_name: "Whisper Small (English)",
disk_size: Megabytes(466),
ram_required: Megabytes(1024),
speed_tier: SpeedTier::Moderate,
accuracy_tier: AccuracyTier::Great,
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-small.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
size: Megabytes(466),
}],
description: "Accuracy-first English transcription",
},
ModelEntry {
id: ModelId::new("whisper-medium-en"),
engine: Engine::Whisper,
display_name: "Whisper Medium (English)",
disk_size: Megabytes(1500),
ram_required: Megabytes(2600),
speed_tier: SpeedTier::Slow,
accuracy_tier: AccuracyTier::Excellent,
languages: LanguageSupport::EnglishOnly,
files: vec![ModelFile {
filename: "ggml-medium.en.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin",
size: Megabytes(1500),
}],
description: "Best Whisper accuracy — needs 4+ GB RAM",
},
]
});
/// Returns all known models. Pure data, no scoring.
pub fn all_models() -> &'static [ModelEntry] {
&ALL_MODELS
}
/// Find a model by its ID.
pub fn find_model(id: &ModelId) -> Option<&'static ModelEntry> {
ALL_MODELS.iter().find(|m| &m.id == id)
}

View File

@@ -0,0 +1,40 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::error::Result;
use crate::types::{AudioSamples, EngineName, Transcript, TranscriptionOptions};
/// Any speech-to-text engine implements this trait.
/// Base types know nothing about their derivatives.
#[async_trait]
pub trait SpeechToText: Send + Sync {
async fn transcribe(
&self,
audio: AudioSamples,
options: &TranscriptionOptions,
) -> Result<Transcript>;
fn name(&self) -> &EngineName;
fn is_available(&self) -> bool;
}
/// Any text post-processor implements this trait.
#[async_trait]
pub trait TextProcessor: Send + Sync {
async fn process(&self, text: &str, instruction: &str) -> Result<String>;
fn name(&self) -> &EngineName;
fn is_available(&self) -> bool;
}
/// Holds the active provider instances. Constructed at startup,
/// rebuilt when user changes provider in settings.
// TODO: Wire into Tauri app state once multi-engine switching is implemented.
#[allow(dead_code)]
pub struct ProviderRegistry {
pub stt: Arc<dyn SpeechToText>,
pub text: Option<Arc<dyn TextProcessor>>,
}

View File

@@ -0,0 +1,180 @@
use crate::hardware::SystemProfile;
use crate::model_registry::{all_models, AccuracyTier, Engine, ModelEntry, SpeedTier};
use crate::types::Megabytes;
/// A model's suitability score for a given system. Higher is better.
/// No boolean flags — position in the ranked list conveys recommendation.
pub struct ScoredModel {
pub entry: &'static ModelEntry,
pub score: f64,
pub reason: String,
}
/// Scores a single model against a system profile.
/// Pure function, no side effects.
pub fn score_model(model: &'static ModelEntry, profile: &SystemProfile) -> Option<ScoredModel> {
if model.ram_required > profile.ram {
return None;
}
let mut score = 0.0;
let mut reasons: Vec<String> = Vec::new();
score += match model.speed_tier {
SpeedTier::Instant => 40.0,
SpeedTier::Fast => 30.0,
SpeedTier::Moderate => 20.0,
SpeedTier::Slow => 10.0,
};
score += match model.accuracy_tier {
AccuracyTier::Excellent => 30.0,
AccuracyTier::Great => 20.0,
AccuracyTier::Good => 10.0,
};
if let Some(gpu) = &profile.gpu {
let has_accel = match model.engine {
Engine::Whisper => {
gpu.acceleration.metal || gpu.acceleration.vulkan || gpu.acceleration.cuda
}
Engine::Parakeet | Engine::Moonshine => {
gpu.acceleration.cuda || gpu.acceleration.vulkan
}
};
if has_accel {
score += 15.0;
reasons.push("GPU accelerated on your system".into());
}
}
let headroom = Megabytes(profile.ram.0.saturating_sub(model.ram_required.0));
if headroom > Megabytes::from_gb(4.0) {
score += 10.0;
}
let reason = if reasons.is_empty() {
model.description.to_string()
} else {
reasons.join(". ")
};
Some(ScoredModel {
entry: model,
score,
reason,
})
}
/// Scores all models and returns them ranked.
/// Index 0 is the recommendation. No flag arguments.
pub fn rank_recommendations(profile: &SystemProfile) -> Vec<ScoredModel> {
let mut scored: Vec<ScoredModel> = all_models()
.iter()
.filter_map(|model| score_model(model, profile))
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hardware::{CpuInfo, GpuAcceleration, GpuInfo, GpuVendor, Os};
fn profile_with_ram(ram: Megabytes) -> SystemProfile {
SystemProfile {
ram,
cpu: CpuInfo {
logical_processors: 8,
brand: "Test CPU".into(),
},
gpu: None,
os: Os::Windows,
}
}
fn profile_with_gpu(ram: Megabytes) -> SystemProfile {
SystemProfile {
ram,
cpu: CpuInfo {
logical_processors: 8,
brand: "Test CPU".into(),
},
gpu: Some(GpuInfo {
vendor: GpuVendor::Nvidia,
vram: Megabytes(8192),
acceleration: GpuAcceleration {
cuda: true,
metal: false,
vulkan: true,
},
}),
os: Os::Windows,
}
}
#[test]
fn score_model_excludes_models_exceeding_available_ram() {
let profile = profile_with_ram(Megabytes(256));
let model = all_models()
.iter()
.find(|m| m.ram_required > Megabytes(256))
.expect("need a model larger than 256 MB");
let result = score_model(model, &profile);
assert!(result.is_none());
}
#[test]
fn score_model_includes_models_fitting_in_ram() {
let profile = profile_with_ram(Megabytes(16384));
let model = &all_models()[0];
let result = score_model(model, &profile);
assert!(result.is_some());
}
#[test]
fn score_model_boosts_gpu_accelerated_models() {
let model = all_models()
.iter()
.find(|m| m.engine == Engine::Parakeet)
.expect("need a Parakeet model");
let gpu_score = score_model(model, &profile_with_gpu(Megabytes(16384)))
.unwrap()
.score;
let cpu_score = score_model(model, &profile_with_ram(Megabytes(16384)))
.unwrap()
.score;
assert!(gpu_score > cpu_score);
}
#[test]
fn rank_recommendations_places_highest_score_first() {
let profile = profile_with_ram(Megabytes(16384));
let ranked = rank_recommendations(&profile);
assert!(ranked.len() >= 2);
assert!(ranked[0].score >= ranked[1].score);
}
#[test]
fn rank_recommendations_returns_empty_for_very_low_ram() {
let profile = profile_with_ram(Megabytes(128));
let ranked = rank_recommendations(&profile);
assert!(ranked.is_empty());
}
}

194
crates/core/src/types.rs Normal file
View File

@@ -0,0 +1,194 @@
use serde::{Deserialize, Serialize};
/// Prevents passing raw strings where model IDs are expected.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelId(String);
impl ModelId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ModelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Prevents passing raw strings where engine names are expected.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineName(String);
impl EngineName {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for EngineName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Prevents mixing up bytes, megabytes, and gigabytes.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct Megabytes(pub u64);
impl Megabytes {
pub fn from_gb(gb: f64) -> Self {
Self((gb * 1024.0) as u64)
}
pub fn as_gb(&self) -> f64 {
self.0 as f64 / 1024.0
}
}
impl std::fmt::Display for Megabytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0 >= 1024 {
write!(f, "{:.1} GB", self.as_gb())
} else {
write!(f, "{} MB", self.0)
}
}
}
/// Wraps raw audio samples with metadata.
#[derive(Debug, Clone)]
pub struct AudioSamples {
samples: Vec<f32>,
sample_rate: u32,
channels: u16,
}
impl AudioSamples {
pub fn new(samples: Vec<f32>, sample_rate: u32, channels: u16) -> Self {
Self {
samples,
sample_rate,
channels,
}
}
pub fn mono_16khz(samples: Vec<f32>) -> Self {
Self {
samples,
sample_rate: crate::constants::WHISPER_SAMPLE_RATE,
channels: crate::constants::WHISPER_CHANNELS,
}
}
pub fn samples(&self) -> &[f32] {
&self.samples
}
pub fn into_samples(self) -> Vec<f32> {
self.samples
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn channels(&self) -> u16 {
self.channels
}
pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 {
return 0.0;
}
self.samples.len() as f64 / self.sample_rate as f64
}
}
/// A single timed segment of a transcription.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Segment {
pub start: f64,
pub end: f64,
pub text: String,
}
/// The result of a transcription.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transcript {
segments: Vec<Segment>,
language: String,
duration: f64,
}
impl Transcript {
pub fn new(segments: Vec<Segment>, language: String, duration: f64) -> Self {
Self {
segments,
language,
duration,
}
}
pub fn text(&self) -> String {
self.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
pub fn language(&self) -> &str {
&self.language
}
pub fn duration(&self) -> f64 {
self.duration
}
}
/// Options passed to a transcription engine.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TranscriptionOptions {
pub language: Option<String>,
pub initial_prompt: Option<String>,
}
/// Full provenance metadata for a transcript.
/// Captures everything needed to reproduce the transcription.
// TODO: Attach to Transcript once the store layer persists transcription provenance.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptMetadata {
pub engine: String,
pub model_id: ModelId,
pub inference_ms: u64,
pub sample_rate: u32,
pub audio_channels: u16,
pub format_mode: String,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
}
/// Progress update during model download.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress {
pub model_id: ModelId,
pub file_name: String,
pub bytes_downloaded: u64,
pub total_bytes: u64,
pub percent: u8,
}

17
crates/storage/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "kon-storage"
version = "0.1.0"
edition = "2021"
description = "SQLite persistence, BM25 search, and file storage for Kon"
[dependencies]
kon-core = { path = "../core" }
# SQLite with compile-time checked queries
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
# Async runtime
tokio = { version = "1", features = ["rt", "sync", "macros"] }
# Logging
log = "0.4"

View File

@@ -0,0 +1,385 @@
use std::path::Path;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use kon_core::error::{KonError, Result};
/// Initialise the SQLite database with connection pool and run migrations.
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.map_err(|e| KonError::StorageError(format!("Database connect failed: {e}")))?;
run_migrations(&pool).await?;
Ok(pool)
}
/// Run schema migrations via the versioned migration system.
async fn run_migrations(pool: &SqlitePool) -> Result<()> {
crate::migrations::run_migrations(pool).await
}
// --- Transcript CRUD ---
/// Parameters for inserting a transcript with full provenance.
pub struct InsertTranscriptParams<'a> {
pub id: &'a str,
pub text: &'a str,
pub source: &'a str,
pub title: Option<&'a str>,
pub audio_path: Option<&'a str>,
pub duration: f64,
pub engine: Option<&'a str>,
pub model_id: Option<&'a str>,
pub inference_ms: Option<i64>,
pub sample_rate: Option<i32>,
pub audio_channels: Option<i32>,
pub format_mode: Option<&'a str>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
}
pub async fn insert_transcript(
pool: &SqlitePool,
params: &InsertTranscriptParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO transcripts (id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(params.id)
.bind(params.text)
.bind(params.source)
.bind(params.title)
.bind(params.audio_path)
.bind(params.duration)
.bind(params.engine)
.bind(params.model_id)
.bind(params.inference_ms)
.bind(params.sample_rate)
.bind(params.audio_channels)
.bind(params.format_mode)
.bind(params.remove_fillers)
.bind(params.british_english)
.bind(params.anti_hallucination)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?;
Ok(())
}
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
let row = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?;
Ok(row.map(|r| transcript_row_from(&r)))
}
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
Ok(rows.iter().map(transcript_row_from).collect())
}
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM transcripts WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Delete transcript failed: {e}")))?;
Ok(())
}
// --- Task CRUD ---
pub async fn insert_task(
pool: &SqlitePool,
id: &str,
text: &str,
bucket: &str,
source_transcript_id: Option<&str>,
) -> Result<()> {
sqlx::query("INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)")
.bind(id)
.bind(text)
.bind(bucket)
.bind(source_transcript_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?;
Ok(())
}
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows =
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC")
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| TaskRow {
id: r.get("id"),
text: r.get("text"),
bucket: r.get("bucket"),
list_id: r.get("list_id"),
effort: r.get("effort"),
done: r.get("done"),
done_at: r.get("done_at"),
created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"),
})
.collect())
}
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Complete task failed: {e}")))?;
Ok(())
}
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Delete task failed: {e}")))?;
Ok(())
}
// --- Settings CRUD ---
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(value)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Set setting failed: {e}")))?;
Ok(())
}
pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT value FROM settings WHERE key = ?")
.bind(key)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get setting failed: {e}")))?;
Ok(row.map(|r| r.get("value")))
}
// --- Row types ---
#[derive(Debug, Clone)]
pub struct TranscriptRow {
pub id: String,
pub text: String,
pub source: String,
pub title: Option<String>,
pub audio_path: Option<String>,
pub duration: f64,
pub engine: Option<String>,
pub model_id: Option<String>,
pub inference_ms: Option<i64>,
pub sample_rate: Option<i32>,
pub audio_channels: Option<i32>,
pub format_mode: Option<String>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
pub created_at: String,
}
#[derive(Debug, Clone)]
pub struct TaskRow {
pub id: String,
pub text: String,
pub bucket: String,
pub list_id: Option<String>,
pub effort: Option<String>,
pub done: bool,
pub done_at: Option<String>,
pub created_at: String,
pub source_transcript_id: Option<String>,
}
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
TranscriptRow {
id: r.get("id"),
text: r.get("text"),
source: r.get("source"),
title: r.get("title"),
audio_path: r.get("audio_path"),
duration: r.get("duration"),
engine: r.get("engine"),
model_id: r.get("model_id"),
inference_ms: r.get("inference_ms"),
sample_rate: r.get("sample_rate"),
audio_channels: r.get("audio_channels"),
format_mode: r.get("format_mode"),
remove_fillers: r.get("remove_fillers"),
british_english: r.get("british_english"),
anti_hallucination: r.get("anti_hallucination"),
created_at: r.get("created_at"),
}
}
// --- Error Logging ---
/// Log a structured error to the `error_log` table.
///
/// Available for Tauri command handlers to persist errors for diagnostics.
/// Each entry records context (which subsystem), an optional error code,
/// the human-readable message, and optional JSON metadata.
///
/// # Example
/// ```ignore
/// log_error(&pool, "transcription", Some("WHISPER_INIT"), "Model load failed", None).await?;
/// ```
///
/// TODO: Wire this into Tauri command error paths so runtime failures are
/// persisted for user-facing error history and crash diagnostics.
pub async fn log_error(
pool: &SqlitePool,
context: &str,
error_code: Option<&str>,
message: &str,
metadata: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)",
)
.bind(context)
.bind(error_code)
.bind(message)
.bind(metadata)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Error log failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
run_migrations(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn transcript_crud_roundtrip() {
let pool = test_pool().await;
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "t1",
text: "Hello world",
source: "microphone",
title: Some("Test"),
audio_path: None,
duration: 1.5,
engine: Some("whisper"),
model_id: Some("whisper-tiny-en"),
inference_ms: Some(250),
sample_rate: Some(48000),
audio_channels: Some(1),
format_mode: Some("Clean"),
remove_fillers: true,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
assert_eq!(t.text, "Hello world");
assert_eq!(t.source, "microphone");
assert_eq!(t.duration, 1.5);
assert_eq!(t.engine.as_deref(), Some("whisper"));
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
assert_eq!(t.inference_ms, Some(250));
assert!(t.remove_fillers);
assert!(t.british_english);
let list = list_transcripts(&pool, 10).await.unwrap();
assert_eq!(list.len(), 1);
delete_transcript(&pool, "t1").await.unwrap();
let deleted = get_transcript(&pool, "t1").await.unwrap();
assert!(deleted.is_none());
}
#[tokio::test]
async fn task_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "task1", "Buy groceries", "today", None)
.await
.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "Buy groceries");
assert!(!tasks[0].done);
complete_task(&pool, "task1").await.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert!(tasks[0].done);
delete_task(&pool, "task1").await.unwrap();
let tasks = list_tasks(&pool).await.unwrap();
assert!(tasks.is_empty());
}
#[tokio::test]
async fn settings_crud_roundtrip() {
let pool = test_pool().await;
set_setting(&pool, "theme", "dark").await.unwrap();
let val = get_setting(&pool, "theme").await.unwrap();
assert_eq!(val.as_deref(), Some("dark"));
set_setting(&pool, "theme", "light").await.unwrap();
let val = get_setting(&pool, "theme").await.unwrap();
assert_eq!(val.as_deref(), Some("light"));
let missing = get_setting(&pool, "nonexistent").await.unwrap();
assert!(missing.is_none());
}
}

View File

@@ -0,0 +1,28 @@
use std::path::PathBuf;
/// Resolve the app data directory.
/// Windows: %LOCALAPPDATA%/kon
/// Unix: ~/.kon
///
/// TODO: Consolidate with `crates/transcription/src/model_manager.rs::dirs_path()`
/// into a shared helper in `crates/core/` to avoid duplicating platform-specific
/// path logic across crates.
pub fn app_data_dir() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon")
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
}
/// Path to the SQLite database file.
pub fn database_path() -> PathBuf {
app_data_dir().join("kon.db")
}
/// Directory for saved audio recordings.
pub fn recordings_dir() -> PathBuf {
app_data_dir().join("recordings")
}

10
crates/storage/src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod database;
pub mod file_storage;
pub mod migrations;
pub use database::{
complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
insert_transcript, list_tasks, list_transcripts, log_error, set_setting,
InsertTranscriptParams, TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, database_path, recordings_dir};

View File

@@ -0,0 +1,168 @@
use sqlx::SqlitePool;
use kon_core::error::{KonError, Result};
/// Each migration is a (version, description, sql) tuple.
/// Migrations MUST be append-only — never modify an existing migration.
/// Column defaults and NOT NULL constraints must exactly match the existing schema.
const MIGRATIONS: &[(i64, &str, &str)] = &[
(1, "initial schema", r#"
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY,
text TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'microphone',
title TEXT,
audio_path TEXT,
duration REAL NOT NULL DEFAULT 0.0,
engine TEXT,
model_id TEXT,
inference_ms INTEGER,
sample_rate INTEGER,
audio_channels INTEGER,
format_mode TEXT,
remove_fillers INTEGER NOT NULL DEFAULT 0,
british_english INTEGER NOT NULL DEFAULT 0,
anti_hallucination INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS segments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
bucket TEXT NOT NULL DEFAULT 'inbox',
list_id TEXT,
effort TEXT,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
source_transcript_id TEXT
);
CREATE TABLE IF NOT EXISTS task_lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
built_in INTEGER NOT NULL DEFAULT 0,
profile_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
context TEXT NOT NULL,
error_code TEXT,
message TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcripts_created ON transcripts(created_at);
CREATE INDEX IF NOT EXISTS idx_segments_transcript ON segments(transcript_id);
CREATE INDEX IF NOT EXISTS idx_tasks_bucket ON tasks(bucket);
CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id);
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
"#),
];
/// Ensure the schema_version table exists and run any pending migrations.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?;
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
for (version, description, sql) in MIGRATIONS {
if *version > current {
log::info!("Running migration {}: {}", version, description);
let statements: Vec<&str> = sql.split(';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for statement in statements {
sqlx::query(statement)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
.bind(version)
.bind(description)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
log::info!("Migration {} complete", version);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
#[tokio::test]
async fn test_migrations_run_on_empty_db() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
run_migrations(&pool).await.unwrap();
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn test_migrations_idempotent() {
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.unwrap();
run_migrations(&pool).await.unwrap();
run_migrations(&pool).await.unwrap();
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM schema_version")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1);
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "kon-transcription"
version = "0.1.0"
edition = "2021"
description = "Speech-to-text engine wrappers, model management, and inference concurrency for Kon"
[dependencies]
kon-core = { path = "../core" }
# Unified STT engine (Parakeet via ONNX, Whisper via whisper.cpp)
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp"] }
# Async runtime for spawn_blocking
tokio = { version = "1", features = ["rt", "sync"] }
# Model downloads
reqwest = { version = "0.12", features = ["stream"] }
futures-util = "0.3"

View File

@@ -0,0 +1,22 @@
use std::sync::Arc;
use kon_core::error::{KonError, Result};
use kon_core::types::{AudioSamples, TranscriptionOptions};
use crate::local_engine::{LocalEngine, TimedTranscript};
/// Runs inference on a blocking thread. Encapsulates all threading concerns.
/// Callers never see spawn_blocking — they call this async function.
pub async fn run_inference(
engine: Arc<LocalEngine>,
audio: AudioSamples,
options: TranscriptionOptions,
) -> Result<TimedTranscript> {
tokio::task::spawn_blocking(move || {
engine.transcribe_sync(&audio, &options)
})
.await
.map_err(|e| {
KonError::TranscriptionFailed(format!("Task join error: {e}"))
})?
}

View File

@@ -0,0 +1,11 @@
pub mod concurrency;
pub mod local_engine;
pub mod model_manager;
pub use concurrency::run_inference;
pub use local_engine::{
load_parakeet, load_whisper, LocalEngine, TimedTranscript,
};
pub use model_manager::{
download, is_downloaded, list_downloaded, model_dir, models_dir,
};

View File

@@ -0,0 +1,155 @@
use std::path::Path;
use std::sync::Mutex;
use std::time::Instant;
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
use kon_core::error::{KonError, Result};
use kon_core::types::{
AudioSamples, EngineName, ModelId, Segment, Transcript,
TranscriptionOptions,
};
/// Result of a timed transcription: transcript + inference duration.
pub struct TimedTranscript {
pub transcript: Transcript,
pub inference_ms: u64,
}
/// Wraps any transcribe-rs engine in Kon's SpeechToText trait.
/// Encapsulates threading: inference always runs on a blocking thread.
/// The rest of the app never imports transcribe-rs directly.
pub struct LocalEngine {
engine: Mutex<Option<Box<dyn SpeechModel + Send>>>,
engine_name: EngineName,
loaded_model_id: Mutex<Option<ModelId>>,
}
impl LocalEngine {
pub fn new(engine_name: EngineName) -> Self {
Self {
engine: Mutex::new(None),
engine_name,
loaded_model_id: Mutex::new(None),
}
}
pub fn load(&self, model: Box<dyn SpeechModel + Send>, model_id: ModelId) {
let mut guard =
self.engine.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(model);
let mut id_guard = self
.loaded_model_id
.lock()
.unwrap_or_else(|e| e.into_inner());
*id_guard = Some(model_id);
}
pub fn name(&self) -> &EngineName {
&self.engine_name
}
pub fn loaded_model_id(&self) -> Option<ModelId> {
let guard = self
.loaded_model_id
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.clone()
}
pub fn is_loaded(&self) -> bool {
let guard =
self.engine.lock().unwrap_or_else(|e| e.into_inner());
guard.is_some()
}
/// Run transcription synchronously with timing.
/// Called from within spawn_blocking.
pub fn transcribe_sync(
&self,
audio: &AudioSamples,
options: &TranscriptionOptions,
) -> Result<TimedTranscript> {
let mut guard =
self.engine.lock().unwrap_or_else(|e| e.into_inner());
let engine =
guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
let opts = TranscribeOptions {
language: options.language.clone(),
translate: false,
};
let start = Instant::now();
let result: TranscriptionResult = engine
.transcribe(audio.samples(), &opts)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
let inference_ms = start.elapsed().as_millis() as u64;
let segments = result
.segments
.unwrap_or_default()
.into_iter()
.map(|s| Segment {
start: s.start as f64,
end: s.end as f64,
text: s.text,
})
.collect();
Ok(TimedTranscript {
transcript: Transcript::new(
segments,
options
.language
.clone()
.unwrap_or_else(|| "en".to_string()),
audio.duration_secs(),
),
inference_ms,
})
}
}
/// Load a Parakeet model from a directory path.
pub fn load_parakeet(
model_dir: &Path,
) -> Result<Box<dyn SpeechModel + Send>> {
use transcribe_rs::onnx::Quantization;
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(
model_dir,
&Quantization::Int8,
)
.map_err(|e| {
KonError::TranscriptionFailed(format!(
"Failed to load Parakeet: {e}"
))
})?;
Ok(Box::new(model))
}
/// Load a Whisper model from a GGML file path.
pub fn load_whisper(
model_path: &Path,
) -> Result<Box<dyn SpeechModel + Send>> {
let engine =
transcribe_rs::whisper_cpp::WhisperEngine::load(model_path)
.map_err(|e| {
KonError::TranscriptionFailed(format!(
"Failed to load Whisper: {e}"
))
})?;
Ok(Box::new(engine))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_reports_not_available_before_loading() {
let engine = LocalEngine::new(EngineName::new("test"));
assert!(!engine.is_loaded());
assert!(engine.loaded_model_id().is_none());
}
}

View File

@@ -0,0 +1,165 @@
use std::path::{Path, PathBuf};
use kon_core::error::{KonError, Result};
use kon_core::model_registry::{find_model, ModelFile};
use kon_core::types::{DownloadProgress, ModelId};
/// Resolve the models storage directory.
/// Windows: %LOCALAPPDATA%/kon/models
/// Unix: ~/.kon/models
pub fn models_dir() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon").join("models")
} else {
dirs_path().join("models")
}
}
fn dirs_path() -> PathBuf {
if cfg!(target_os = "windows") {
let local_app_data = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(local_app_data).join("kon")
} else {
let home =
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".kon")
}
}
/// Get the directory path where a specific model's files are stored.
pub fn model_dir(id: &ModelId) -> PathBuf {
models_dir().join(id.as_str())
}
/// Check whether all files for a model have been downloaded.
pub fn is_downloaded(id: &ModelId) -> bool {
let entry = match find_model(id) {
Some(e) => e,
None => return false,
};
let dir = model_dir(id);
entry.files.iter().all(|f| dir.join(f.filename).exists())
}
/// List all downloaded model IDs.
pub fn list_downloaded() -> Vec<ModelId> {
kon_core::model_registry::all_models()
.iter()
.filter(|m| is_downloaded(&m.id))
.map(|m| m.id.clone())
.collect()
}
/// Download all files for a model, calling the progress callback per chunk.
/// Files are downloaded to a .part suffix and atomically renamed on completion.
pub async fn download(
id: &ModelId,
progress: impl Fn(DownloadProgress) + Send + 'static,
) -> Result<()> {
let entry = find_model(id)
.ok_or_else(|| KonError::ModelNotFound(id.clone()))?;
let dir = model_dir(id);
std::fs::create_dir_all(&dir)?;
for file in &entry.files {
let dest = dir.join(file.filename);
if dest.exists() {
continue;
}
download_file(file, &dest, id, &progress).await?;
}
Ok(())
}
async fn download_file(
file: &ModelFile,
dest: &Path,
model_id: &ModelId,
progress: &(impl Fn(DownloadProgress) + Send),
) -> Result<()> {
use futures_util::StreamExt;
let part_path = dest.with_extension(
dest.extension()
.map(|e| format!("{}.part", e.to_string_lossy()))
.unwrap_or_else(|| "part".to_string()),
);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
let response = client
.get(file.url)
.send()
.await
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
let total_bytes = response.content_length().unwrap_or(0);
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
let mut last_percent: u8 = 0;
let mut out = std::fs::File::create(&part_path)?;
while let Some(chunk) = stream.next().await {
let chunk = chunk
.map_err(|e| KonError::DownloadFailed(e.to_string()))?;
std::io::Write::write_all(&mut out, &chunk)?;
downloaded += chunk.len() as u64;
let percent = if total_bytes > 0 {
((downloaded as f64 / total_bytes as f64) * 100.0) as u8
} else {
0
};
if percent != last_percent {
last_percent = percent;
progress(DownloadProgress {
model_id: model_id.clone(),
file_name: file.filename.to_string(),
bytes_downloaded: downloaded,
total_bytes,
percent,
});
}
}
drop(out);
std::fs::rename(&part_path, dest)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_dir_returns_correct_path() {
let id = ModelId::new("whisper-tiny-en");
let path = model_dir(&id);
assert!(path.to_string_lossy().contains("whisper-tiny-en"));
}
#[test]
fn is_downloaded_returns_false_for_missing_model() {
let id = ModelId::new("nonexistent-model");
assert!(!is_downloaded(&id));
}
#[test]
fn list_downloaded_returns_empty_when_no_models() {
let list = list_downloaded();
// In test environment, no models are downloaded
// This just verifies the function doesn't panic
assert!(list.len() <= kon_core::model_registry::all_models().len());
}
}