feat(kon): add transcription crate — transcribe-rs wrapper, model manager

- LocalEngine wraps transcribe-rs SpeechModel behind Kon's own abstraction
- load_parakeet() and load_whisper() factory functions
- Unified model manager: download with progress callback, check, list, path resolution
- Atomic downloads: .part suffix with rename on completion
- run_inference() encapsulates spawn_blocking threading
- Zero Tauri dependency in this crate (progress via callback, not events)
- transcribe-rs v0.3.2 with onnx + whisper-cpp features
- 4 tests passing, clippy clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:39:37 +00:00
parent 0738fca22c
commit 100ecb4eae
5 changed files with 320 additions and 2 deletions

View File

@@ -0,0 +1,118 @@
use std::path::Path;
use std::sync::Mutex;
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
use kon_core::error::{KonError, Result};
use kon_core::types::{
AudioSamples, EngineName, Segment, Transcript, TranscriptionOptions,
};
/// 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,
}
impl LocalEngine {
pub fn new(engine_name: EngineName) -> Self {
Self {
engine: Mutex::new(None),
engine_name,
}
}
pub fn load(&self, model: Box<dyn SpeechModel + Send>) {
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(model);
}
pub fn name(&self) -> &EngineName {
&self.engine_name
}
pub fn is_loaded(&self) -> bool {
let guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
guard.is_some()
}
/// Run transcription synchronously. Called from within spawn_blocking.
pub fn transcribe_sync(
&self,
audio: &AudioSamples,
options: &TranscriptionOptions,
) -> Result<Transcript> {
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 result: TranscriptionResult = engine
.transcribe(audio.samples(), &opts)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
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(Transcript::new(
segments,
options
.language
.clone()
.unwrap_or_else(|| "en".to_string()),
audio.duration_secs(),
))
}
}
/// Load a Parakeet model from a directory path.
/// The directory should contain model_int8.onnx (+ .onnx_data) and tokenizer.json.
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());
}
}