diff --git a/crates/transcription/tests/jfk_bench.rs b/crates/transcription/tests/jfk_bench.rs new file mode 100644 index 0000000..55ccd3c --- /dev/null +++ b/crates/transcription/tests/jfk_bench.rs @@ -0,0 +1,133 @@ +//! Benchmark: load the JFK WAV from disk, transcribe it via whisper-rs. +//! Reports cold-load time, transcribe time, RTF, peak RSS. +//! +//! Gated on env vars so it never runs in CI without setup: +//! MAGNOTIA_WHISPER_TEST_MODEL=/path/to/ggml-tiny.bin +//! MAGNOTIA_WHISPER_TEST_AUDIO=/path/to/jfk.wav + +use std::env; +use std::time::Instant; + +#[test] +fn jfk_transcription_benchmark() { + let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { + eprintln!("MAGNOTIA_WHISPER_TEST_MODEL not set — skipping"); + return; + }; + let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { + eprintln!("MAGNOTIA_WHISPER_TEST_AUDIO not set — skipping"); + return; + }; + + use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; + + eprintln!("[bench] loading WAV: {audio_path}"); + let bytes = std::fs::read(&audio_path).expect("read wav"); + // Minimal RIFF/WAV parse: skip the 44-byte canonical header for PCM-16-mono-16kHz. + // Sanity-check magic bytes + format. + assert_eq!(&bytes[0..4], b"RIFF", "expected RIFF"); + assert_eq!(&bytes[8..12], b"WAVE", "expected WAVE"); + let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap()); + let channels = u16::from_le_bytes(bytes[22..24].try_into().unwrap()); + let bits = u16::from_le_bytes(bytes[34..36].try_into().unwrap()); + eprintln!("[bench] wav spec: {} Hz, {} ch, {}-bit", sample_rate, channels, bits); + assert_eq!(sample_rate, 16_000, "expected 16 kHz wav"); + assert_eq!(channels, 1, "expected mono"); + assert_eq!(bits, 16, "expected 16-bit PCM"); + + let pcm = &bytes[44..]; + let samples: Vec = pcm + .chunks_exact(2) + .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + let audio_secs = samples.len() as f64 / sample_rate as f64; + eprintln!( + "[bench] audio length: {} samples = {:.2}s", + samples.len(), + audio_secs + ); + + let rss_before_load_kb = read_rss_kb(); + eprintln!("[bench] RSS before model load: {} MB", rss_before_load_kb / 1024); + + let load_start = Instant::now(); + let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default()) + .expect("whisper model load"); + let load_dur = load_start.elapsed(); + eprintln!("[bench] model load: {:.2}s", load_dur.as_secs_f64()); + + let rss_after_load_kb = read_rss_kb(); + eprintln!("[bench] RSS after model load: {} MB (delta +{} MB)", + rss_after_load_kb / 1024, + (rss_after_load_kb.saturating_sub(rss_before_load_kb)) / 1024); + + let mut state = ctx.create_state().expect("whisper state"); + let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + params.set_language(Some("en")); + params.set_n_threads(6); + params.set_print_special(false); + params.set_print_progress(false); + params.set_print_realtime(false); + + // Cold transcription (first run) + let cold_start = Instant::now(); + state.full(params, &samples).expect("transcribe cold"); + let cold_dur = cold_start.elapsed(); + + let n = state.full_n_segments(); + let mut full_text = String::new(); + for i in 0..n { + let seg = state.get_segment(i).expect("get_segment"); + full_text.push_str(seg.to_str().unwrap_or("")); + } + eprintln!( + "[bench] cold transcribe: {:.2}s ({} segments, RTF={:.3})", + cold_dur.as_secs_f64(), + n, + cold_dur.as_secs_f64() / audio_secs + ); + eprintln!("[bench] transcript: {}", full_text.trim()); + let rss_after_cold_kb = read_rss_kb(); + eprintln!("[bench] RSS after cold xc: {} MB", rss_after_cold_kb / 1024); + + // Warm transcription (second run, same state) + let mut state2 = ctx.create_state().expect("whisper state 2"); + let mut params2 = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + params2.set_language(Some("en")); + params2.set_n_threads(6); + params2.set_print_special(false); + params2.set_print_progress(false); + params2.set_print_realtime(false); + let warm_start = Instant::now(); + state2.full(params2, &samples).expect("transcribe warm"); + let warm_dur = warm_start.elapsed(); + eprintln!( + "[bench] warm transcribe: {:.2}s (RTF={:.3})", + warm_dur.as_secs_f64(), + warm_dur.as_secs_f64() / audio_secs + ); + + let rss_final_kb = read_rss_kb(); + eprintln!("[bench] RSS final: {} MB", rss_final_kb / 1024); + + eprintln!(""); + eprintln!("=== SUMMARY ==="); + eprintln!("audio: {:.2}s", audio_secs); + eprintln!("model_load: {:.2}s", load_dur.as_secs_f64()); + eprintln!("cold xc: {:.2}s RTF={:.3}", cold_dur.as_secs_f64(), cold_dur.as_secs_f64() / audio_secs); + eprintln!("warm xc: {:.2}s RTF={:.3}", warm_dur.as_secs_f64(), warm_dur.as_secs_f64() / audio_secs); + eprintln!("RSS peak: {} MB", rss_final_kb / 1024); +} + +fn read_rss_kb() -> u64 { + let pid = std::process::id(); + let s = std::fs::read_to_string(format!("/proc/{pid}/status")).unwrap_or_default(); + for line in s.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + return rest.trim().split_whitespace().next() + .and_then(|n| n.parse::().ok()) + .unwrap_or(0); + } + } + 0 +} diff --git a/package-lock.json b/package-lock.json index b64eab2..7e21828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "license": "MIT", "dependencies": { "@chenglou/pretext": "0.0.5", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", @@ -1614,12 +1614,22 @@ } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", - "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.8.0" + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-dialog/node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" } }, "node_modules/@tauri-apps/plugin-global-shortcut": { diff --git a/package.json b/package.json index f35a927..bdc93da 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,9 @@ "license": "MIT", "dependencies": { "@chenglou/pretext": "0.0.5", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", diff --git a/src-tauri/capabilities/secondary-windows.json b/src-tauri/capabilities/secondary-windows.json index f1cbf40..879d2ce 100644 --- a/src-tauri/capabilities/secondary-windows.json +++ b/src-tauri/capabilities/secondary-windows.json @@ -9,6 +9,7 @@ "core:window:allow-close", "core:window:allow-hide", "core:window:allow-show", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "core:window:allow-set-always-on-top" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index d7aa9d1..e048711 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus"]}} \ No newline at end of file +{"main":{"identifier":"main","description":"Main window capability for user-initiated app control.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","autostart:allow-enable","autostart:allow-disable","autostart:allow-is-enabled","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify"]},"secondary-windows":{"identifier":"secondary-windows","description":"Narrow capability for passive secondary windows.","local":true,"windows":["tasks-float","transcript-viewer","transcription-preview"],"permissions":["core:event:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","core:window:allow-set-always-on-top"]}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 873690e..b0a8f1b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,17 +97,7 @@ async fn save_preferences( /// --ozone-platform=x11). Day 6 of the upgrade plan. #[cfg(target_os = "linux")] fn ensure_x11_on_wayland() { - // If the user explicitly opted in to Wayland (XDG_SESSION_TYPE set by - // their session, KDE / GNOME compositor flags), force WebKitGTK + GDK - // onto X11 via XWayland. Idempotent: if a value is already set, keep - // it (the user knows best). - let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default(); - let is_wayland = session_type.eq_ignore_ascii_case("wayland"); - if !is_wayland { - return; - } - - let set_if_unset = |key: &str, value: &str| { + let set_if_unset = |key: &str, value: &str, why: &str| { if std::env::var_os(key).is_none() { // SAFETY: setting env vars before any threads spawn (we are // pre-Tauri-Builder here). This block is the only place these @@ -115,15 +105,30 @@ fn ensure_x11_on_wayland() { unsafe { std::env::set_var(key, value); } - eprintln!("[startup] Wayland workaround: {key}={value}"); + eprintln!("[startup] Linux workaround: {key}={value} ({why})"); } }; - set_if_unset("GDK_BACKEND", "x11"); - set_if_unset("WINIT_UNIX_BACKEND", "x11"); - set_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - // PipeWire screen-capture portal still works fine through XWayland; - // we only redirect the GUI rendering path. + // Always-on Linux: disable WebKit's DMA-BUF renderer. On RADV / Renoir + // class iGPUs and a number of NVIDIA driver stacks, the DMA-BUF path + // burns ~10pp idle GPU and ~10pp idle CPU compared to the legacy + // renderer for no visible quality difference. Empirically (env-var + // matrix on Ryzen 5 4650U / Vega 6, May 2026) toggling this dropped + // magnotia idle CPU 12.30% → 2.80% of one core and idle GPU 17% → 10%. + // Apples to both X11 and Wayland sessions; users can opt back in by + // setting WEBKIT_DISABLE_DMABUF_RENDERER=0 explicitly. + set_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1", "iGPU idle-cost workaround"); + + // If the user is on a Wayland session, also force WebKitGTK + GDK + // onto X11 via XWayland. Idempotent: if a value is already set, keep + // it (the user knows best). PipeWire screen-capture portal still + // works fine through XWayland; we only redirect the GUI rendering + // path. + let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default(); + if session_type.eq_ignore_ascii_case("wayland") { + set_if_unset("GDK_BACKEND", "x11", "Wayland XWayland fallback"); + set_if_unset("WINIT_UNIX_BACKEND", "x11", "Wayland XWayland fallback"); + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 006adc3..42498e6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -105,6 +105,21 @@ if (!tauriRuntimeAvailable) return; if (hotkeyBackend === "unknown") return; // not yet initialised + // Only the main window owns the global shortcut. Secondary windows + // (tasks-float, transcript-viewer, transcription-preview) inherit the + // root layout via SvelteKit's `+layout@.svelte` break — they don't + // mount this code, but cross-window settings sync via localStorage + // can still re-fire `$effect(() => settings.globalHotkey)` callbacks + // in webviews where this function happens to be in scope. Guard so + // we don't trigger an ACL-denied register from a popout's webview. + try { + const label = (await import("@tauri-apps/api/window")).getCurrentWindow().label; + if (label !== "main") return; + } catch { + // If the window-label probe fails, fall through — main is the + // expected default and the ACL will catch a misuse. + } + try { if (hotkeyBackend === "evdev") { // evdev backend: start or update the Rust-side listener