perf+fix: DMABUF default on Linux, popout ACL fixes, plugin version sync, JFK bench fixture

Bundled work from a low-end laptop (Ryzen 5 4650U / Vega 6 / Linux Mint
22.2 / X11) profiling pass.

perf: WEBKIT_DISABLE_DMABUF_RENDERER=1 default on all Linux
  Previously only set on Wayland sessions. Empirically it's a
  significant idle-cost win on integrated GPUs in either session type:
  env-var matrix (release binary, 75s settle, 10s jiffies CPU sample)
  showed magnotia idle CPU 12.30% → 2.80% of one core and idle GPU
  17% → 10% on this hardware. Users can opt back in by exporting
  WEBKIT_DISABLE_DMABUF_RENDERER=0. The Wayland-only XWayland
  fallback (GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11) is unchanged.

fix: secondary-windows ACL — allow set-always-on-top
  The float window's pin toggle calls setAlwaysOnTop() but the
  secondary-windows capability didn't permit it, so the popout was
  stuck always-on-top regardless of the pin state. Adds the
  core:window:allow-set-always-on-top permission. Narrow scope.

fix: guard registerGlobalHotkey against non-main webviews
  Cross-window settings sync via localStorage can re-fire the
  $effect(() => settings.globalHotkey) callback inside popout webviews
  where the main layout's registerGlobalHotkey is reachable. Adds an
  early-return when the current window label is not "main", so the
  popout doesn't trigger an ACL-denied register/unregister and the
  user no longer sees a spurious "Hotkey not registered" toast when
  popouts are open. Keeps the global-shortcut perm scoped to main.

build: pin @tauri-apps/api 2.10.1 + @tauri-apps/plugin-dialog 2.7.1
  Match the Rust crate versions tauri-cli's version-mismatch check
  enforces during release builds. Without this, `npm run tauri build`
  exits 0 silently while emitting an Error and never producing
  binaries.

test: add crates/transcription/tests/jfk_bench.rs
  Reproducible RTF regression fixture. Env-gated on
  MAGNOTIA_WHISPER_TEST_MODEL + MAGNOTIA_WHISPER_TEST_AUDIO so it
  never runs in CI without setup. Loads the JFK WAV inline (no hound
  dep), times model load + cold + warm transcribe, prints SUMMARY.
  Baselines on this hardware:
    --release --features whisper:               cold RTF 0.054, warm 0.050, RSS 125 MB
    --release --features whisper,whisper-vulkan: cold RTF 0.029, warm 0.028, RSS 125 MB
  Vulkan on RADV/Vega 6 nearly halves transcription latency for
  Whisper Tiny — useful baseline for Phase 10 hardware-recommendation
  scoring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jars
2026-05-09 09:09:03 +01:00
parent 2dfd7167fd
commit fdf27db0a1
7 changed files with 191 additions and 27 deletions

View File

@@ -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<f32> = 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::<u64>().ok())
.unwrap_or(0);
}
}
0
}

22
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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)]

View File

@@ -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