diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29a85ec..d77e655 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,3 +30,6 @@ tauri-plugin-global-shortcut = "2" # Serialisation serde = { version = "1", features = ["derive"] } serde_json = "1" + +# Async runtime (spawn_blocking for inference) +tokio = { version = "1", features = ["rt", "sync"] } diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs new file mode 100644 index 0000000..51d72d6 --- /dev/null +++ b/src-tauri/src/commands/audio.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +use tauri::Manager; + +use kon_core::types::AudioSamples; + +/// Save PCM f32 samples as a WAV file. Returns the file path. +#[tauri::command] +pub async fn save_audio( + app: tauri::AppHandle, + samples: Vec, + output_folder: Option, +) -> Result { + let recordings_dir = if let Some(ref folder) = output_folder { + if !folder.is_empty() { + PathBuf::from(folder) + } else { + app.path() + .app_local_data_dir() + .map_err(|e: tauri::Error| e.to_string())? + .join("recordings") + } + } else { + app.path() + .app_local_data_dir() + .map_err(|e: tauri::Error| e.to_string())? + .join("recordings") + }; + + std::fs::create_dir_all(&recordings_dir) + .map_err(|e| format!("Failed to create recordings dir: {e}"))?; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let filename = format!("kon-{timestamp}.wav"); + let path = recordings_dir.join(&filename); + let path_clone = path.clone(); + + tokio::task::spawn_blocking(move || { + let audio = AudioSamples::mono_16khz(samples); + kon_audio::write_wav(&path_clone, &audio).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/commands/llm.rs b/src-tauri/src/commands/llm.rs new file mode 100644 index 0000000..b5b1411 --- /dev/null +++ b/src-tauri/src/commands/llm.rs @@ -0,0 +1,53 @@ +// LLM stubs — ONNX-based local LLM coming post-revenue. + +#[tauri::command] +pub fn check_llm_engine() -> Result { + Ok(false) +} + +#[tauri::command] +pub fn check_llm_model(_size: String) -> Result { + Ok(false) +} + +#[tauri::command] +pub fn list_llm_models() -> Result, String> { + Ok(vec![]) +} + +#[tauri::command] +pub async fn download_llm_model( + _app: tauri::AppHandle, + _size: String, +) -> Result { + Err("LLM not available yet — coming soon".into()) +} + +#[tauri::command] +pub async fn load_llm_model( + _size: String, +) -> Result { + Err("LLM not available yet — coming soon".into()) +} + +#[tauri::command] +pub async fn llm_generate( + _prompt: String, + _max_tokens: Option, +) -> Result { + Err("LLM not available yet — coming soon".into()) +} + +#[tauri::command] +pub async fn llm_extract_tasks( + _transcript: String, +) -> Result, String> { + Err("LLM not available yet — coming soon".into()) +} + +#[tauri::command] +pub async fn llm_format_transcript( + _transcript: String, +) -> Result { + Err("LLM not available yet — coming soon".into()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..40acba1 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod audio; +pub mod llm; +pub mod models; +pub mod transcription; +pub mod windows; diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs new file mode 100644 index 0000000..c5714d3 --- /dev/null +++ b/src-tauri/src/commands/models.rs @@ -0,0 +1,172 @@ +use tauri::Emitter; + +use crate::AppState; +use kon_core::types::ModelId; +use kon_transcription::model_manager; + +/// Map legacy size strings to ModelId. +fn whisper_model_id(size: &str) -> ModelId { + match size.to_lowercase().as_str() { + "tiny" => ModelId::new("whisper-tiny-en"), + "base" => ModelId::new("whisper-base-en"), + "small" => ModelId::new("whisper-small-en"), + "medium" => ModelId::new("whisper-medium-en"), + other => ModelId::new(other), + } +} + +fn parakeet_model_id(name: &str) -> ModelId { + match name { + "ctc-int8" => ModelId::new("parakeet-ctc-0.6b-int8"), + other => ModelId::new(other), + } +} + +// --- Whisper model commands --- + +#[tauri::command] +pub async fn download_model( + app: tauri::AppHandle, + size: String, +) -> Result { + let id = whisper_model_id(&size); + let app_clone = app.clone(); + model_manager::download(&id, move |progress| { + let _ = app_clone.emit("model-download-progress", &progress); + }) + .await + .map_err(|e| e.to_string())?; + Ok(format!("Model {} downloaded", size)) +} + +#[tauri::command] +pub fn check_model(size: String) -> Result { + let id = whisper_model_id(&size); + Ok(model_manager::is_downloaded(&id)) +} + +#[tauri::command] +pub fn list_models() -> Result, String> { + let ids = model_manager::list_downloaded(); + Ok(ids + .into_iter() + .filter(|id| id.as_str().starts_with("whisper-")) + .map(|id| { + match id.as_str() { + "whisper-tiny-en" => "Tiny".to_string(), + "whisper-base-en" => "Base".to_string(), + "whisper-small-en" => "Small".to_string(), + "whisper-medium-en" => "Medium".to_string(), + other => other.to_string(), + } + }) + .collect()) +} + +#[tauri::command] +pub async fn load_model( + state: tauri::State<'_, AppState>, + size: String, +) -> Result { + let id = whisper_model_id(&size); + let dir = model_manager::model_dir(&id); + let model_file = dir.join( + kon_core::model_registry::find_model(&id) + .ok_or_else(|| format!("Unknown model: {size}"))? + .files + .first() + .ok_or_else(|| format!("No files for model: {size}"))? + .filename, + ); + + if !model_file.exists() { + return Err(format!("Model not downloaded: {size}")); + } + + let engine = state.whisper_engine.clone(); + tokio::task::spawn_blocking(move || { + let model = kon_transcription::load_whisper(&model_file) + .map_err(|e| e.to_string())?; + engine.load(model); + Ok::<_, String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(format!("Model {} loaded", size)) +} + +#[tauri::command] +pub fn check_engine(state: tauri::State) -> Result { + Ok(state.whisper_engine.is_loaded()) +} + +// --- Parakeet model commands --- + +#[tauri::command] +pub async fn download_parakeet_model( + app: tauri::AppHandle, + name: String, +) -> Result { + let id = parakeet_model_id(&name); + let app_clone = app.clone(); + model_manager::download(&id, move |progress| { + let _ = app_clone.emit("parakeet-download-progress", &progress); + }) + .await + .map_err(|e| e.to_string())?; + Ok(format!("Parakeet model {} downloaded", name)) +} + +#[tauri::command] +pub fn check_parakeet_model(name: String) -> Result { + let id = parakeet_model_id(&name); + Ok(model_manager::is_downloaded(&id)) +} + +#[tauri::command] +pub fn list_parakeet_models() -> Result, String> { + let ids = model_manager::list_downloaded(); + Ok(ids + .into_iter() + .filter(|id| id.as_str().starts_with("parakeet-")) + .map(|id| { + match id.as_str() { + "parakeet-ctc-0.6b-int8" => "ctc-int8".to_string(), + other => other.to_string(), + } + }) + .collect()) +} + +#[tauri::command] +pub async fn load_parakeet_model( + state: tauri::State<'_, AppState>, + name: String, +) -> Result { + let id = parakeet_model_id(&name); + let dir = model_manager::model_dir(&id); + + if !dir.exists() { + return Err(format!("Parakeet model not downloaded: {name}")); + } + + let engine = state.parakeet_engine.clone(); + tokio::task::spawn_blocking(move || { + let model = kon_transcription::load_parakeet(&dir) + .map_err(|e| e.to_string())?; + engine.load(model); + Ok::<_, String>(()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(format!("Parakeet model {} loaded", name)) +} + +#[tauri::command] +pub fn check_parakeet_engine( + state: tauri::State, +) -> Result { + Ok(state.parakeet_engine.is_loaded()) +} diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs new file mode 100644 index 0000000..dbc8ccc --- /dev/null +++ b/src-tauri/src/commands/transcription.rs @@ -0,0 +1,165 @@ +// Tauri command handlers must match the frontend's invoke() parameter lists, +// so the argument counts are dictated by the Svelte code. +#![allow(clippy::too_many_arguments)] + +use std::path::Path; + +use tauri::Emitter; + +use crate::AppState; +use kon_ai_formatting::{post_process_segments, FormatMode, PostProcessOptions}; +use kon_core::types::{Segment, TranscriptionOptions}; + +/// Transcribe raw PCM f32 samples (Whisper). Emits "transcription-result" event. +#[tauri::command] +pub async fn transcribe_pcm( + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, + samples: Vec, + chunk_id: u32, + language: String, + initial_prompt: String, + remove_fillers: bool, + british_english: bool, + anti_hallucination: bool, + format_mode: String, +) -> Result<(), String> { + let engine = state.whisper_engine.clone(); + let audio = kon_core::AudioSamples::mono_16khz(samples); + let options = TranscriptionOptions { + language: Some(language), + 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) + }) + .await + .map_err(|e| e.to_string())??; + + let mut segments: Vec = result.segments().to_vec(); + post_process_segments( + &mut segments, + &PostProcessOptions { + remove_fillers, + british_english, + anti_hallucination, + format_mode: FormatMode::parse(&format_mode), + }, + ); + + app.emit( + "transcription-result", + serde_json::json!({ + "status": "transcription", + "segments": segments, + "language": result.language(), + "duration": result.duration(), + "chunk_id": chunk_id, + }), + ) + .map_err(|e| format!("Failed to emit result: {e}"))?; + + Ok(()) +} + +/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper. +#[tauri::command] +pub async fn transcribe_file( + state: tauri::State<'_, AppState>, + path: String, + language: String, + initial_prompt: String, + remove_fillers: bool, + british_english: bool, + anti_hallucination: bool, + format_mode: String, +) -> Result { + let engine = state.whisper_engine.clone(); + let options = TranscriptionOptions { + language: Some(language), + initial_prompt: Some(initial_prompt), + }; + + let result = 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 + .transcribe_sync(&resampled, &options) + .map_err(|e| e.to_string())?; + Ok::<_, String>(transcript) + }) + .await + .map_err(|e| e.to_string())??; + + let mut segments: Vec = result.segments().to_vec(); + post_process_segments( + &mut segments, + &PostProcessOptions { + remove_fillers, + british_english, + anti_hallucination, + format_mode: FormatMode::parse(&format_mode), + }, + ); + + Ok(serde_json::json!({ + "segments": segments, + "language": result.language(), + "duration": result.duration(), + })) +} + +/// Transcribe raw PCM f32 samples (Parakeet). Emits "transcription-result" event. +#[tauri::command] +pub async fn transcribe_pcm_parakeet( + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, + samples: Vec, + chunk_id: u32, + remove_fillers: bool, + british_english: bool, + anti_hallucination: bool, +) -> Result<(), String> { + let engine = state.parakeet_engine.clone(); + 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) + }) + .await + .map_err(|e| e.to_string())??; + + let mut segments: Vec = result.segments().to_vec(); + post_process_segments( + &mut segments, + &PostProcessOptions { + remove_fillers, + british_english, + anti_hallucination, + format_mode: FormatMode::parse("Clean"), + }, + ); + + app.emit( + "transcription-result", + serde_json::json!({ + "status": "transcription", + "segments": segments, + "language": result.language(), + "duration": result.duration(), + "chunk_id": chunk_id, + }), + ) + .map_err(|e| format!("Failed to emit result: {e}"))?; + + Ok(()) +} diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs new file mode 100644 index 0000000..234d380 --- /dev/null +++ b/src-tauri/src/commands/windows.rs @@ -0,0 +1,58 @@ +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Open a floating always-on-top task window. +#[tauri::command] +pub async fn open_task_window( + app: tauri::AppHandle, +) -> Result<(), String> { + if let Some(window) = app.get_webview_window("tasks-float") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + app.emit("task-window-focus", ()) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new( + &app, + "tasks-float", + WebviewUrl::App("/float".into()), + ) + .title("Kon Tasks") + .inner_size(480.0, 520.0) + .min_inner_size(400.0, 400.0) + .always_on_top(true) + .decorations(false) + .resizable(true) + .build() + .map_err(|e| e.to_string())?; + + Ok(()) +} + +/// Open the transcript viewer window. +#[tauri::command] +pub async fn open_viewer_window( + app: tauri::AppHandle, +) -> Result<(), String> { + if let Some(window) = app.get_webview_window("transcript-viewer") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new( + &app, + "transcript-viewer", + WebviewUrl::App("/viewer".into()), + ) + .title("Kon - Viewer") + .inner_size(600.0, 700.0) + .min_inner_size(450.0, 500.0) + .decorations(false) + .resizable(true) + .build() + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 349c213..359bba6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,83 @@ +mod commands; +mod tray; + +use std::sync::Arc; + +use tauri::Manager; + +use kon_core::types::EngineName; +use kon_transcription::LocalEngine; + +/// Shared app state holding the transcription engines. +pub struct AppState { + pub whisper_engine: Arc, + pub parakeet_engine: Arc, +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .manage(AppState { + whisper_engine: Arc::new(LocalEngine::new( + EngineName::new("whisper"), + )), + parakeet_engine: Arc::new(LocalEngine::new( + EngineName::new("parakeet"), + )), + }) + .setup(|app| { + if let Err(e) = tray::setup(app) { + eprintln!("Failed to setup tray: {e}"); + } + + // Close-to-tray: hide window instead of exiting + let main_window = app.get_webview_window("main").unwrap(); + let win = main_window.clone(); + main_window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event + { + api.prevent_close(); + let _ = win.hide(); + } + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + // Whisper model management + commands::models::download_model, + commands::models::check_model, + commands::models::list_models, + commands::models::load_model, + commands::models::check_engine, + // Parakeet model management + commands::models::download_parakeet_model, + commands::models::check_parakeet_model, + commands::models::list_parakeet_models, + commands::models::load_parakeet_model, + commands::models::check_parakeet_engine, + // Transcription + commands::transcription::transcribe_pcm, + commands::transcription::transcribe_file, + commands::transcription::transcribe_pcm_parakeet, + // Audio + commands::audio::save_audio, + // Windows + commands::windows::open_task_window, + commands::windows::open_viewer_window, + // LLM stubs + commands::llm::download_llm_model, + commands::llm::check_llm_model, + commands::llm::list_llm_models, + commands::llm::load_llm_model, + commands::llm::check_llm_engine, + commands::llm::llm_generate, + commands::llm::llm_extract_tasks, + commands::llm::llm_format_transcript, + ]) .run(tauri::generate_context!()) .expect("error while running Kon"); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..f635ea4 --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,59 @@ +use tauri::image::Image; +use tauri::menu::{MenuBuilder, MenuItemBuilder}; +use tauri::tray::TrayIconBuilder; +use tauri::Manager; + +pub fn setup(app: &tauri::App) -> Result<(), Box> { + let show = MenuItemBuilder::with_id("show", "Show Kon").build(app)?; + let status = MenuItemBuilder::with_id("status", "Ready") + .enabled(false) + .build(app)?; + let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; + + let menu = MenuBuilder::new(app) + .item(&show) + .separator() + .item(&status) + .separator() + .item(&quit) + .build()?; + + let icon = app + .default_window_icon() + .cloned() + .unwrap_or_else(|| Image::new(&[0u8; 4], 1, 1)); + + let _tray = TrayIconBuilder::new() + .icon(icon) + .tooltip("Kon — Ready") + .menu(&menu) + .on_menu_event(move |app, event| match event.id().as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let tauri::tray::TrayIconEvent::Click { + button: tauri::tray::MouseButton::Left, + .. + } = event + { + if let Some(window) = + tray.app_handle().get_webview_window("main") + { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + Ok(()) +}