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:
118
crates/transcription/src/local_engine.rs
Normal file
118
crates/transcription/src/local_engine.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user