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