feat(kon): add full transcript provenance — enriched schema, metadata pipeline, error log

Schema enrichment:
- transcripts table: +engine, +model_id, +inference_ms, +sample_rate, +audio_channels,
  +format_mode, +remove_fillers, +british_english, +anti_hallucination
- New error_log table: context, error_code, message, metadata (JSON)
- 5 indices on frequently queried columns

Metadata pipeline:
- TranscriptMetadata struct in kon-core for full provenance capture
- LocalEngine tracks loaded ModelId, returns TimedTranscript with inference_ms
- std::time::Instant timing around transcribe-rs inference calls
- Tauri transcription commands emit inference_ms in event payloads
- InsertTranscriptParams accepts all provenance fields
- log_error() for structured error logging to SQLite

Every transcript is now fully reproducible from its stored metadata.
23 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 21:36:44 +00:00
parent 29ff91d3f6
commit 292f9716ff
9 changed files with 276 additions and 100 deletions

View File

@@ -9,5 +9,5 @@ pub mod types;
pub use error::{KonError, Result}; pub use error::{KonError, Result};
pub use types::{ pub use types::{
AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment, AudioSamples, DownloadProgress, EngineName, Megabytes, ModelId, Segment,
Transcript, TranscriptionOptions, Transcript, TranscriptMetadata, TranscriptionOptions,
}; };

View File

@@ -166,6 +166,21 @@ pub struct TranscriptionOptions {
pub initial_prompt: Option<String>, pub initial_prompt: Option<String>,
} }
/// 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. /// Progress update during model download.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadProgress { pub struct DownloadProgress {

View File

@@ -36,6 +36,15 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> {
title TEXT, title TEXT,
audio_path TEXT, audio_path TEXT,
duration REAL NOT NULL DEFAULT 0.0, 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')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
)", )",
) )
@@ -96,29 +105,81 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> {
.await .await
.map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?; .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(()) Ok(())
} }
// --- Transcript CRUD --- // --- 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( pub async fn insert_transcript(
pool: &SqlitePool, pool: &SqlitePool,
id: &str, params: &InsertTranscriptParams<'_>,
text: &str,
source: &str,
title: Option<&str>,
audio_path: Option<&str>,
duration: f64,
) -> Result<()> { ) -> Result<()> {
sqlx::query( 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(params.id)
.bind(text) .bind(params.text)
.bind(source) .bind(params.source)
.bind(title) .bind(params.title)
.bind(audio_path) .bind(params.audio_path)
.bind(duration) .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) .execute(pool)
.await .await
.map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?; .map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?;
@@ -130,45 +191,26 @@ pub async fn get_transcript(
id: &str, id: &str,
) -> Result<Option<TranscriptRow>> { ) -> Result<Option<TranscriptRow>> {
let row = sqlx::query( 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) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?; .map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?;
Ok(row.map(|r| TranscriptRow { Ok(row.map(|r| transcript_row_from(&r)))
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"),
}))
} }
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> { pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query( 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) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?; .map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
Ok(rows Ok(rows.iter().map(transcript_row_from).collect())
.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())
} }
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -274,6 +316,15 @@ pub struct TranscriptRow {
pub title: Option<String>, pub title: Option<String>,
pub audio_path: Option<String>, pub audio_path: Option<String>,
pub duration: f64, 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, pub created_at: String,
} }
@@ -290,6 +341,50 @@ pub struct TaskRow {
pub source_transcript_id: Option<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.
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -307,14 +402,38 @@ mod tests {
async fn transcript_crud_roundtrip() { async fn transcript_crud_roundtrip() {
let pool = test_pool().await; let pool = test_pool().await;
insert_transcript(&pool, "t1", "Hello world", "microphone", Some("Test"), None, 1.5) insert_transcript(
.await &pool,
.unwrap(); &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(); let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
assert_eq!(t.text, "Hello world"); assert_eq!(t.text, "Hello world");
assert_eq!(t.source, "microphone"); assert_eq!(t.source, "microphone");
assert_eq!(t.duration, 1.5); 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(); let list = list_transcripts(&pool, 10).await.unwrap();
assert_eq!(list.len(), 1); assert_eq!(list.len(), 1);

View File

@@ -4,6 +4,6 @@ pub mod file_storage;
pub use database::{ pub use database::{
complete_task, delete_task, delete_transcript, get_setting, get_transcript, complete_task, delete_task, delete_transcript, get_setting, get_transcript,
init, insert_task, insert_transcript, list_tasks, list_transcripts, 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}; pub use file_storage::{app_data_dir, database_path, recordings_dir};

View File

@@ -1,9 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use kon_core::error::{KonError, Result}; 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. /// Runs inference on a blocking thread. Encapsulates all threading concerns.
/// Callers never see spawn_blocking — they call this async function. /// Callers never see spawn_blocking — they call this async function.
@@ -11,10 +11,12 @@ pub async fn run_inference(
engine: Arc<LocalEngine>, engine: Arc<LocalEngine>,
audio: AudioSamples, audio: AudioSamples,
options: TranscriptionOptions, options: TranscriptionOptions,
) -> Result<Transcript> { ) -> Result<TimedTranscript> {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
engine.transcribe_sync(&audio, &options) engine.transcribe_sync(&audio, &options)
}) })
.await .await
.map_err(|e| KonError::TranscriptionFailed(format!("Task join error: {e}")))? .map_err(|e| {
KonError::TranscriptionFailed(format!("Task join error: {e}"))
})?
} }

View File

@@ -3,5 +3,9 @@ pub mod local_engine;
pub mod model_manager; pub mod model_manager;
pub use concurrency::run_inference; pub use concurrency::run_inference;
pub use local_engine::{load_parakeet, load_whisper, LocalEngine}; pub use local_engine::{
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir}; load_parakeet, load_whisper, LocalEngine, TimedTranscript,
};
pub use model_manager::{
download, is_downloaded, list_downloaded, model_dir, models_dir,
};

View File

@@ -1,19 +1,28 @@
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Instant;
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult}; use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
use kon_core::error::{KonError, Result}; use kon_core::error::{KonError, Result};
use kon_core::types::{ 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. /// Wraps any transcribe-rs engine in Kon's SpeechToText trait.
/// Encapsulates threading: inference always runs on a blocking thread. /// Encapsulates threading: inference always runs on a blocking thread.
/// The rest of the app never imports transcribe-rs directly. /// The rest of the app never imports transcribe-rs directly.
pub struct LocalEngine { pub struct LocalEngine {
engine: Mutex<Option<Box<dyn SpeechModel + Send>>>, engine: Mutex<Option<Box<dyn SpeechModel + Send>>>,
engine_name: EngineName, engine_name: EngineName,
loaded_model_id: Mutex<Option<ModelId>>,
} }
impl LocalEngine { impl LocalEngine {
@@ -21,43 +30,61 @@ impl LocalEngine {
Self { Self {
engine: Mutex::new(None), engine: Mutex::new(None),
engine_name, engine_name,
loaded_model_id: Mutex::new(None),
} }
} }
pub fn load(&self, model: Box<dyn SpeechModel + Send>) { 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()); let mut guard =
self.engine.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(model); *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 { pub fn name(&self) -> &EngineName {
&self.engine_name &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 { 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() 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( pub fn transcribe_sync(
&self, &self,
audio: &AudioSamples, audio: &AudioSamples,
options: &TranscriptionOptions, options: &TranscriptionOptions,
) -> Result<Transcript> { ) -> Result<TimedTranscript> {
let mut guard = let mut guard =
self.engine.lock().unwrap_or_else(|e| e.into_inner()); self.engine.lock().unwrap_or_else(|e| e.into_inner());
let engine = guard let engine =
.as_mut() guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
.ok_or(KonError::EngineNotLoaded)?;
let opts = TranscribeOptions { let opts = TranscribeOptions {
language: options.language.clone(), language: options.language.clone(),
translate: false, translate: false,
}; };
let start = Instant::now();
let result: TranscriptionResult = engine let result: TranscriptionResult = engine
.transcribe(audio.samples(), &opts) .transcribe(audio.samples(), &opts)
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?; .map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
let inference_ms = start.elapsed().as_millis() as u64;
let segments = result let segments = result
.segments .segments
@@ -70,39 +97,48 @@ impl LocalEngine {
}) })
.collect(); .collect();
Ok(Transcript::new( Ok(TimedTranscript {
segments, transcript: Transcript::new(
options segments,
.language options
.clone() .language
.unwrap_or_else(|| "en".to_string()), .clone()
audio.duration_secs(), .unwrap_or_else(|| "en".to_string()),
)) audio.duration_secs(),
),
inference_ms,
})
} }
} }
/// Load a Parakeet model from a directory path. /// Load a Parakeet model from a directory path.
/// The directory should contain model_int8.onnx (+ .onnx_data) and tokenizer.json. pub fn load_parakeet(
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn SpeechModel + Send>> { model_dir: &Path,
) -> Result<Box<dyn SpeechModel + Send>> {
use transcribe_rs::onnx::Quantization; use transcribe_rs::onnx::Quantization;
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load( let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(
model_dir, model_dir,
&Quantization::Int8, &Quantization::Int8,
) )
.map_err(|e| { .map_err(|e| {
KonError::TranscriptionFailed(format!("Failed to load Parakeet: {e}")) KonError::TranscriptionFailed(format!(
"Failed to load Parakeet: {e}"
))
})?; })?;
Ok(Box::new(model)) Ok(Box::new(model))
} }
/// Load a Whisper model from a GGML file path. /// Load a Whisper model from a GGML file path.
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn SpeechModel + Send>> { pub fn load_whisper(
let engine = transcribe_rs::whisper_cpp::WhisperEngine::load(model_path) model_path: &Path,
.map_err(|e| { ) -> Result<Box<dyn SpeechModel + Send>> {
KonError::TranscriptionFailed(format!( let engine =
"Failed to load Whisper: {e}" transcribe_rs::whisper_cpp::WhisperEngine::load(model_path)
)) .map_err(|e| {
})?; KonError::TranscriptionFailed(format!(
"Failed to load Whisper: {e}"
))
})?;
Ok(Box::new(engine)) Ok(Box::new(engine))
} }
@@ -114,5 +150,6 @@ mod tests {
fn engine_reports_not_available_before_loading() { fn engine_reports_not_available_before_loading() {
let engine = LocalEngine::new(EngineName::new("test")); let engine = LocalEngine::new(EngineName::new("test"));
assert!(!engine.is_loaded()); assert!(!engine.is_loaded());
assert!(engine.loaded_model_id().is_none());
} }
} }

View File

@@ -84,10 +84,11 @@ pub async fn load_model(
} }
let engine = state.whisper_engine.clone(); let engine = state.whisper_engine.clone();
let mid = id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let model = kon_transcription::load_whisper(&model_file) let model = kon_transcription::load_whisper(&model_file)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
engine.load(model); engine.load(model, mid);
Ok::<_, String>(()) Ok::<_, String>(())
}) })
.await .await
@@ -152,10 +153,11 @@ pub async fn load_parakeet_model(
} }
let engine = state.parakeet_engine.clone(); let engine = state.parakeet_engine.clone();
let mid = id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let model = kon_transcription::load_parakeet(&dir) let model = kon_transcription::load_parakeet(&dir)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
engine.load(model); engine.load(model, mid);
Ok::<_, String>(()) Ok::<_, String>(())
}) })
.await .await

View File

@@ -31,15 +31,13 @@ pub async fn transcribe_pcm(
initial_prompt: Some(initial_prompt), initial_prompt: Some(initial_prompt),
}; };
let result = tokio::task::spawn_blocking(move || { let timed = tokio::task::spawn_blocking(move || {
let transcript = engine.transcribe_sync(&audio, &options) engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
Ok::<_, String>(transcript)
}) })
.await .await
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec(); let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
post_process_segments( post_process_segments(
&mut segments, &mut segments,
&PostProcessOptions { &PostProcessOptions {
@@ -55,9 +53,10 @@ pub async fn transcribe_pcm(
serde_json::json!({ serde_json::json!({
"status": "transcription", "status": "transcription",
"segments": segments, "segments": segments,
"language": result.language(), "language": timed.transcript.language(),
"duration": result.duration(), "duration": timed.transcript.duration(),
"chunk_id": chunk_id, "chunk_id": chunk_id,
"inference_ms": timed.inference_ms,
}), }),
) )
.map_err(|e| format!("Failed to emit result: {e}"))?; .map_err(|e| format!("Failed to emit result: {e}"))?;
@@ -83,20 +82,19 @@ pub async fn transcribe_file(
initial_prompt: Some(initial_prompt), 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)) let audio = kon_audio::decode_audio_file(Path::new(&path))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let resampled = kon_audio::resample_to_16khz(&audio) let resampled =
.map_err(|e| e.to_string())?; kon_audio::resample_to_16khz(&audio).map_err(|e| e.to_string())?;
let transcript = engine engine
.transcribe_sync(&resampled, &options) .transcribe_sync(&resampled, &options)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())
Ok::<_, String>(transcript)
}) })
.await .await
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec(); let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
post_process_segments( post_process_segments(
&mut segments, &mut segments,
&PostProcessOptions { &PostProcessOptions {
@@ -109,8 +107,9 @@ pub async fn transcribe_file(
Ok(serde_json::json!({ Ok(serde_json::json!({
"segments": segments, "segments": segments,
"language": result.language(), "language": timed.transcript.language(),
"duration": result.duration(), "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 audio = kon_core::AudioSamples::mono_16khz(samples);
let options = TranscriptionOptions::default(); let options = TranscriptionOptions::default();
let result = tokio::task::spawn_blocking(move || { let timed = tokio::task::spawn_blocking(move || {
let transcript = engine engine.transcribe_sync(&audio, &options).map_err(|e| e.to_string())
.transcribe_sync(&audio, &options)
.map_err(|e| e.to_string())?;
Ok::<_, String>(transcript)
}) })
.await .await
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
let mut segments: Vec<Segment> = result.segments().to_vec(); let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
post_process_segments( post_process_segments(
&mut segments, &mut segments,
&PostProcessOptions { &PostProcessOptions {
@@ -154,9 +150,10 @@ pub async fn transcribe_pcm_parakeet(
serde_json::json!({ serde_json::json!({
"status": "transcription", "status": "transcription",
"segments": segments, "segments": segments,
"language": result.language(), "language": timed.transcript.language(),
"duration": result.duration(), "duration": timed.transcript.duration(),
"chunk_id": chunk_id, "chunk_id": chunk_id,
"inference_ms": timed.inference_ms,
}), }),
) )
.map_err(|e| format!("Failed to emit result: {e}"))?; .map_err(|e| format!("Failed to emit result: {e}"))?;