diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ebfccdf..9084835 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,5 +9,5 @@ pub mod types; pub use error::{KonError, Result}; pub use types::{ AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, - Transcript, TranscriptionOptions, + Transcript, TranscriptMetadata, TranscriptionOptions, }; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index ff0eb74..8555a64 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -166,6 +166,21 @@ pub struct TranscriptionOptions { pub initial_prompt: Option, } +/// Full provenance metadata for a transcript. +/// Captures everything needed to reproduce the transcription. +#[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 { diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index c077138..027fbce 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -36,6 +36,15 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> { 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')) )", ) @@ -96,29 +105,81 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> { .await .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + sqlx::query( + "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 + )", + ) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; + + // Indices for frequently queried columns + for ddl in [ + "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)", + ] { + sqlx::query(ddl) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Index creation failed: {e}")))?; + } + Ok(()) } // --- 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, + pub sample_rate: Option, + pub audio_channels: Option, + 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, - id: &str, - text: &str, - source: &str, - title: Option<&str>, - audio_path: Option<&str>, - duration: f64, + params: &InsertTranscriptParams<'_>, ) -> Result<()> { sqlx::query( - "INSERT INTO transcripts (id, text, source, title, audio_path, duration) VALUES (?, ?, ?, ?, ?, ?)", + "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(id) - .bind(text) - .bind(source) - .bind(title) - .bind(audio_path) - .bind(duration) + .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}")))?; @@ -130,45 +191,26 @@ pub async fn get_transcript( id: &str, ) -> Result> { let row = sqlx::query( - "SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts WHERE id = ?", + "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| 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"), - created_at: r.get("created_at"), - })) + Ok(row.map(|r| transcript_row_from(&r))) } pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result> { let rows = sqlx::query( - "SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?", + "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 - .into_iter() - .map(|r| 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"), - created_at: r.get("created_at"), - }) - .collect()) + Ok(rows.iter().map(transcript_row_from).collect()) } pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { @@ -274,6 +316,15 @@ pub struct TranscriptRow { pub title: Option, pub audio_path: Option, pub duration: f64, + pub engine: Option, + pub model_id: Option, + pub inference_ms: Option, + pub sample_rate: Option, + pub audio_channels: Option, + pub format_mode: Option, + pub remove_fillers: bool, + pub british_english: bool, + pub anti_hallucination: bool, pub created_at: String, } @@ -290,6 +341,50 @@ pub struct TaskRow { pub source_transcript_id: Option, } +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. +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::*; @@ -307,14 +402,38 @@ mod tests { async fn transcript_crud_roundtrip() { let pool = test_pool().await; - insert_transcript(&pool, "t1", "Hello world", "microphone", Some("Test"), None, 1.5) - .await - .unwrap(); + 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); diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index b50e9f3..69c42bd 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -4,6 +4,6 @@ pub mod file_storage; pub use database::{ complete_task, delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, insert_transcript, list_tasks, list_transcripts, - set_setting, TaskRow, TranscriptRow, + log_error, set_setting, InsertTranscriptParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, database_path, recordings_dir}; diff --git a/crates/transcription/src/concurrency.rs b/crates/transcription/src/concurrency.rs index d5e0199..52c1d8e 100644 --- a/crates/transcription/src/concurrency.rs +++ b/crates/transcription/src/concurrency.rs @@ -1,9 +1,9 @@ use std::sync::Arc; use kon_core::error::{KonError, Result}; -use kon_core::types::{AudioSamples, Transcript, TranscriptionOptions}; +use kon_core::types::{AudioSamples, TranscriptionOptions}; -use crate::local_engine::LocalEngine; +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. @@ -11,10 +11,12 @@ pub async fn run_inference( engine: Arc, audio: AudioSamples, options: TranscriptionOptions, -) -> Result { +) -> Result { tokio::task::spawn_blocking(move || { engine.transcribe_sync(&audio, &options) }) .await - .map_err(|e| KonError::TranscriptionFailed(format!("Task join error: {e}")))? + .map_err(|e| { + KonError::TranscriptionFailed(format!("Task join error: {e}")) + })? } diff --git a/crates/transcription/src/lib.rs b/crates/transcription/src/lib.rs index 6c82485..b43b365 100644 --- a/crates/transcription/src/lib.rs +++ b/crates/transcription/src/lib.rs @@ -3,5 +3,9 @@ pub mod local_engine; pub mod model_manager; pub use concurrency::run_inference; -pub use local_engine::{load_parakeet, load_whisper, LocalEngine}; -pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir}; +pub use local_engine::{ + load_parakeet, load_whisper, LocalEngine, TimedTranscript, +}; +pub use model_manager::{ + download, is_downloaded, list_downloaded, model_dir, models_dir, +}; diff --git a/crates/transcription/src/local_engine.rs b/crates/transcription/src/local_engine.rs index 25355dc..026b026 100644 --- a/crates/transcription/src/local_engine.rs +++ b/crates/transcription/src/local_engine.rs @@ -1,19 +1,28 @@ 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, Segment, Transcript, TranscriptionOptions, + 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>>, engine_name: EngineName, + loaded_model_id: Mutex>, } impl LocalEngine { @@ -21,43 +30,61 @@ impl LocalEngine { Self { engine: Mutex::new(None), engine_name, + loaded_model_id: Mutex::new(None), } } - pub fn load(&self, model: Box) { - let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner()); + pub fn load(&self, model: Box, 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 { + 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()); + let guard = + self.engine.lock().unwrap_or_else(|e| e.into_inner()); guard.is_some() } - /// Run transcription synchronously. Called from within spawn_blocking. + /// Run transcription synchronously with timing. + /// Called from within spawn_blocking. pub fn transcribe_sync( &self, audio: &AudioSamples, options: &TranscriptionOptions, - ) -> Result { + ) -> Result { let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner()); - let engine = guard - .as_mut() - .ok_or(KonError::EngineNotLoaded)?; + 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 @@ -70,39 +97,48 @@ impl LocalEngine { }) .collect(); - Ok(Transcript::new( - segments, - options - .language - .clone() - .unwrap_or_else(|| "en".to_string()), - audio.duration_secs(), - )) + 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. -/// The directory should contain model_int8.onnx (+ .onnx_data) and tokenizer.json. -pub fn load_parakeet(model_dir: &Path) -> Result> { +pub fn load_parakeet( + model_dir: &Path, +) -> Result> { 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}")) + 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> { - let engine = transcribe_rs::whisper_cpp::WhisperEngine::load(model_path) - .map_err(|e| { - KonError::TranscriptionFailed(format!( - "Failed to load Whisper: {e}" - )) - })?; +pub fn load_whisper( + model_path: &Path, +) -> Result> { + 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)) } @@ -114,5 +150,6 @@ mod tests { 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()); } } diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index c5714d3..bcde847 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -84,10 +84,11 @@ pub async fn load_model( } let engine = state.whisper_engine.clone(); + let mid = id.clone(); tokio::task::spawn_blocking(move || { let model = kon_transcription::load_whisper(&model_file) .map_err(|e| e.to_string())?; - engine.load(model); + engine.load(model, mid); Ok::<_, String>(()) }) .await @@ -152,10 +153,11 @@ pub async fn load_parakeet_model( } let engine = state.parakeet_engine.clone(); + let mid = id.clone(); tokio::task::spawn_blocking(move || { let model = kon_transcription::load_parakeet(&dir) .map_err(|e| e.to_string())?; - engine.load(model); + engine.load(model, mid); Ok::<_, String>(()) }) .await diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs index dbc8ccc..a6ba2bb 100644 --- a/src-tauri/src/commands/transcription.rs +++ b/src-tauri/src/commands/transcription.rs @@ -31,15 +31,13 @@ pub async fn transcribe_pcm( initial_prompt: Some(initial_prompt), }; - let result = tokio::task::spawn_blocking(move || { - let transcript = engine.transcribe_sync(&audio, &options) - .map_err(|e| e.to_string())?; - Ok::<_, String>(transcript) + let timed = tokio::task::spawn_blocking(move || { + engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut segments: Vec = result.segments().to_vec(); + let mut segments: Vec = timed.transcript.segments().to_vec(); post_process_segments( &mut segments, &PostProcessOptions { @@ -55,9 +53,10 @@ pub async fn transcribe_pcm( serde_json::json!({ "status": "transcription", "segments": segments, - "language": result.language(), - "duration": result.duration(), + "language": timed.transcript.language(), + "duration": timed.transcript.duration(), "chunk_id": chunk_id, + "inference_ms": timed.inference_ms, }), ) .map_err(|e| format!("Failed to emit result: {e}"))?; @@ -83,20 +82,19 @@ pub async fn transcribe_file( initial_prompt: Some(initial_prompt), }; - let result = tokio::task::spawn_blocking(move || { + let timed = tokio::task::spawn_blocking(move || { let audio = kon_audio::decode_audio_file(Path::new(&path)) .map_err(|e| e.to_string())?; - let resampled = kon_audio::resample_to_16khz(&audio) - .map_err(|e| e.to_string())?; - let transcript = engine + let resampled = + kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?; + engine .transcribe_sync(&resampled, &options) - .map_err(|e| e.to_string())?; - Ok::<_, String>(transcript) + .map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut segments: Vec = result.segments().to_vec(); + let mut segments: Vec = timed.transcript.segments().to_vec(); post_process_segments( &mut segments, &PostProcessOptions { @@ -109,8 +107,9 @@ pub async fn transcribe_file( Ok(serde_json::json!({ "segments": segments, - "language": result.language(), - "duration": result.duration(), + "language": timed.transcript.language(), + "duration": timed.transcript.duration(), + "inference_ms": timed.inference_ms, })) } @@ -129,16 +128,13 @@ pub async fn transcribe_pcm_parakeet( let audio = kon_core::AudioSamples::mono_16khz(samples); let options = TranscriptionOptions::default(); - let result = tokio::task::spawn_blocking(move || { - let transcript = engine - .transcribe_sync(&audio, &options) - .map_err(|e| e.to_string())?; - Ok::<_, String>(transcript) + let timed = tokio::task::spawn_blocking(move || { + engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; - let mut segments: Vec = result.segments().to_vec(); + let mut segments: Vec = timed.transcript.segments().to_vec(); post_process_segments( &mut segments, &PostProcessOptions { @@ -154,9 +150,10 @@ pub async fn transcribe_pcm_parakeet( serde_json::json!({ "status": "transcription", "segments": segments, - "language": result.language(), - "duration": result.duration(), + "language": timed.transcript.language(), + "duration": timed.transcript.duration(), "chunk_id": chunk_id, + "inference_ms": timed.inference_ms, }), ) .map_err(|e| format!("Failed to emit result: {e}"))?;