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:
9
crates/ai-formatting/Cargo.toml
Normal file
9
crates/ai-formatting/Cargo.toml
Normal 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"
|
||||
6
crates/ai-formatting/src/lib.rs
Normal file
6
crates/ai-formatting/src/lib.rs
Normal 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};
|
||||
5
crates/ai-formatting/src/llm_client.rs
Normal file
5
crates/ai-formatting/src/llm_client.rs
Normal 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.
|
||||
119
crates/ai-formatting/src/pipeline.rs
Normal file
119
crates/ai-formatting/src/pipeline.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
291
crates/ai-formatting/src/rule_based.rs
Normal file
291
crates/ai-formatting/src/rule_based.rs
Normal 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: ®ex_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
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();
|
||||
}
|
||||
}
|
||||
8
crates/cloud-providers/Cargo.toml
Normal file
8
crates/cloud-providers/Cargo.toml
Normal 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" }
|
||||
29
crates/cloud-providers/src/keystore.rs
Normal file
29
crates/cloud-providers/src/keystore.rs
Normal 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()
|
||||
}
|
||||
3
crates/cloud-providers/src/lib.rs
Normal file
3
crates/cloud-providers/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod keystore;
|
||||
|
||||
pub use keystore::{retrieve_api_key, store_api_key};
|
||||
12
crates/core/Cargo.toml
Normal file
12
crates/core/Cargo.toml
Normal 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"
|
||||
49
crates/core/src/constants.rs
Normal file
49
crates/core/src/constants.rs
Normal 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
60
crates/core/src/error.rs
Normal 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
105
crates/core/src/hardware.rs
Normal 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
13
crates/core/src/lib.rs
Normal 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,
|
||||
};
|
||||
166
crates/core/src/model_registry.rs
Normal file
166
crates/core/src/model_registry.rs
Normal 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)
|
||||
}
|
||||
40
crates/core/src/providers.rs
Normal file
40
crates/core/src/providers.rs
Normal 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>>,
|
||||
}
|
||||
180
crates/core/src/recommendation.rs
Normal file
180
crates/core/src/recommendation.rs
Normal 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
194
crates/core/src/types.rs
Normal 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
17
crates/storage/Cargo.toml
Normal 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"
|
||||
385
crates/storage/src/database.rs
Normal file
385
crates/storage/src/database.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
28
crates/storage/src/file_storage.rs
Normal file
28
crates/storage/src/file_storage.rs
Normal 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
10
crates/storage/src/lib.rs
Normal 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};
|
||||
168
crates/storage/src/migrations.rs
Normal file
168
crates/storage/src/migrations.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
18
crates/transcription/Cargo.toml
Normal file
18
crates/transcription/Cargo.toml
Normal 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"
|
||||
22
crates/transcription/src/concurrency.rs
Normal file
22
crates/transcription/src/concurrency.rs
Normal 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}"))
|
||||
})?
|
||||
}
|
||||
11
crates/transcription/src/lib.rs
Normal file
11
crates/transcription/src/lib.rs
Normal 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,
|
||||
};
|
||||
155
crates/transcription/src/local_engine.rs
Normal file
155
crates/transcription/src/local_engine.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
165
crates/transcription/src/model_manager.rs
Normal file
165
crates/transcription/src/model_manager.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user