Compare commits
2 Commits
2dfd7167fd
...
4cb954ece4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb954ece4 | ||
|
|
fdf27db0a1 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2855,6 +2855,7 @@ name = "magnotia-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"num_cpus",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
@@ -2882,7 +2883,6 @@ dependencies = [
|
||||
"futures-util",
|
||||
"llama-cpp-2",
|
||||
"magnotia-core",
|
||||
"num_cpus",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -10,3 +10,4 @@ serde_json = "1"
|
||||
thiserror = "2"
|
||||
sysinfo = "0.35"
|
||||
async-trait = "0.1"
|
||||
num_cpus = "1"
|
||||
|
||||
@@ -18,8 +18,21 @@ pub const MIN_CHUNK_SAMPLES: usize = 8000;
|
||||
/// Post-processing thresholds.
|
||||
pub const SMART_PARAGRAPH_GAP_SECS: f64 = 2.0;
|
||||
|
||||
/// Thread count for inference. Leaves headroom for the UI thread.
|
||||
pub const MIN_INFERENCE_THREADS: usize = 4;
|
||||
/// Lower bound for inference threads. Single-threaded inference is
|
||||
/// measurably worse than two on every multi-core part; we never go below
|
||||
/// 2. (Whisper Tiny is the exception where the work is so small that
|
||||
/// thread count barely matters — but the floor still costs nothing.)
|
||||
pub const MIN_INFERENCE_THREADS: usize = 2;
|
||||
|
||||
/// Upper bound for inference threads. Both whisper.cpp and llama.cpp
|
||||
/// scaling flattens around physical core count; SMT siblings contend
|
||||
/// for shared FPU resources during heavy F16/F32 matmul, which means
|
||||
/// going past physical cores often anti-scales. Empirical evidence:
|
||||
/// whisper.cpp issue #200 (sweet spot at 7t on 8c/16t Ryzen 3700X),
|
||||
/// llama.cpp #3167 ("SMT hurts inference"). 8 is a conservative
|
||||
/// ceiling that leaves <5% on the table for big-iron desktops while
|
||||
/// keeping consumer 6c/12t laptops out of contention territory.
|
||||
pub const MAX_INFERENCE_THREADS: usize = 8;
|
||||
|
||||
/// History limits.
|
||||
pub const HISTORY_MAX_ENTRIES: usize = 100;
|
||||
@@ -40,10 +53,37 @@ pub const VAD_SPEECH_PAD_MS: u32 = 100;
|
||||
/// Model download chunk size for progress reporting.
|
||||
pub const DOWNLOAD_CHUNK_BYTES: usize = 65_536;
|
||||
|
||||
/// Inference thread count based on available parallelism.
|
||||
/// Inference thread count, clamped to physical-core budget.
|
||||
///
|
||||
/// Returns the system's physical-core count, clamped to
|
||||
/// `[MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS]`. Uses physical
|
||||
/// rather than logical cores because SMT/hyperthreaded siblings
|
||||
/// contend for shared FPU resources during heavy matmul; counting
|
||||
/// them as additional workers is well-documented to anti-scale on
|
||||
/// both whisper.cpp and llama.cpp.
|
||||
///
|
||||
/// Falls back to `available_parallelism` only if the physical-core
|
||||
/// probe is unavailable (some non-Linux/non-Windows platforms or
|
||||
/// containerised environments).
|
||||
///
|
||||
/// Users can override at runtime by setting
|
||||
/// `MAGNOTIA_INFERENCE_THREADS=N` — useful for benchmarking and for
|
||||
/// users on big-iron desktops who want to push past the cap.
|
||||
pub fn inference_thread_count() -> usize {
|
||||
if let Ok(s) = std::env::var("MAGNOTIA_INFERENCE_THREADS") {
|
||||
if let Ok(n) = s.parse::<usize>() {
|
||||
if n > 0 {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
let physical = num_cpus::get_physical();
|
||||
let chosen = if physical > 0 {
|
||||
physical
|
||||
} else {
|
||||
std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(1))
|
||||
.map(|p| p.get())
|
||||
.unwrap_or(MIN_INFERENCE_THREADS)
|
||||
.max(MIN_INFERENCE_THREADS)
|
||||
};
|
||||
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ magnotia-core = { path = "../core" }
|
||||
encoding_rs = "0.8"
|
||||
futures-util = "0.3"
|
||||
llama-cpp-2 = { version = "0.1.144", default-features = false }
|
||||
num_cpus = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -163,7 +163,8 @@ impl LlmEngine {
|
||||
}
|
||||
|
||||
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
|
||||
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||
let thread_count = i32::try_from(magnotia_core::constants::inference_thread_count())
|
||||
.unwrap_or(4);
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(Some(
|
||||
NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero"),
|
||||
|
||||
@@ -17,7 +17,7 @@ build = "build.rs"
|
||||
# but skip the Vulkan backend. Build CPU-only with:
|
||||
# cargo build -p magnotia-transcription --no-default-features --features whisper
|
||||
default = ["whisper", "whisper-vulkan"]
|
||||
whisper = ["dep:whisper-rs", "dep:num_cpus"]
|
||||
whisper = ["dep:whisper-rs"]
|
||||
whisper-vulkan = ["whisper-rs?/vulkan"]
|
||||
|
||||
[dependencies]
|
||||
@@ -40,10 +40,6 @@ sha2 = "0.10"
|
||||
# additive via the `whisper-vulkan` feature so non-GPU targets can drop it.
|
||||
whisper-rs = { version = "0.16", default-features = false, optional = true }
|
||||
|
||||
# Direct whisper-rs backend (WhisperRsBackend): thread pool sizing.
|
||||
# Gated alongside whisper-rs since no other code in this crate needs it.
|
||||
num_cpus = { version = "1", optional = true }
|
||||
|
||||
# Typed error enum used by WhisperRsBackend + elsewhere. Kept
|
||||
# unconditional because it is a derive-macro crate with negligible
|
||||
# build cost.
|
||||
@@ -56,3 +52,7 @@ tracing = "0.1"
|
||||
# TcpListener fixture for the download resume tests (mirrors magnotia-llm).
|
||||
tokio = { version = "1", features = ["rt", "sync", "net", "io-util", "macros"] }
|
||||
tempfile = "3"
|
||||
# Test-only — used by tests/thread_sweep.rs to label physical vs logical
|
||||
# core counts in the scaling table. Production code uses the
|
||||
# `magnotia_core::constants::inference_thread_count` helper instead.
|
||||
num_cpus = "1"
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::path::Path;
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
use magnotia_core::constants::inference_thread_count;
|
||||
use magnotia_core::error::{MagnotiaError, Result};
|
||||
use magnotia_core::types::{Segment, TranscriptionOptions};
|
||||
|
||||
@@ -77,7 +78,7 @@ impl Transcriber for WhisperRsBackend {
|
||||
params.set_initial_prompt(prompt);
|
||||
}
|
||||
}
|
||||
params.set_n_threads(num_cpus::get() as i32);
|
||||
params.set_n_threads(inference_thread_count() as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
133
crates/transcription/tests/jfk_bench.rs
Normal file
133
crates/transcription/tests/jfk_bench.rs
Normal 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
|
||||
}
|
||||
76
crates/transcription/tests/thread_sweep.rs
Normal file
76
crates/transcription/tests/thread_sweep.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Thread-count scaling sweep for Whisper Tiny.
|
||||
//! Runs the JFK clip at n_threads = 1, 2, 4, 6, 8, 12, prints RTF table.
|
||||
//! Gated on the same env vars as jfk_bench.
|
||||
|
||||
use std::env;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn whisper_thread_count_sweep() {
|
||||
let Ok(model_path) = env::var("MAGNOTIA_WHISPER_TEST_MODEL") else { return };
|
||||
let Ok(audio_path) = env::var("MAGNOTIA_WHISPER_TEST_AUDIO") else { return };
|
||||
|
||||
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||
|
||||
let bytes = std::fs::read(&audio_path).expect("read wav");
|
||||
let sample_rate = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
|
||||
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!("[sweep] audio: {:.2}s @ {} Hz", audio_secs, sample_rate);
|
||||
|
||||
let logical = num_cpus::get();
|
||||
let physical = num_cpus::get_physical();
|
||||
eprintln!("[sweep] CPU: physical={}, logical={}", physical, logical);
|
||||
|
||||
let ctx = WhisperContext::new_with_params(&model_path, WhisperContextParameters::default())
|
||||
.expect("model load");
|
||||
|
||||
// Warm-up pass at default to prime caches
|
||||
{
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(physical as i32);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
state.full(params, &samples).expect("warmup");
|
||||
}
|
||||
|
||||
let mut targets: Vec<i32> = vec![1, 2, 4, physical as i32, logical as i32];
|
||||
if logical >= 8 && !targets.contains(&8) { targets.push(8); }
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
|
||||
eprintln!("");
|
||||
eprintln!("=== n_threads scaling ===");
|
||||
eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
|
||||
eprintln!("----------|---------|--------|-------------");
|
||||
let mut baseline_dur: Option<f64> = None;
|
||||
for n in &targets {
|
||||
// Two runs, take the min (deeper of L2/L3 effects; we want best-case)
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..2 {
|
||||
let mut state = ctx.create_state().expect("state");
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
params.set_language(Some("en"));
|
||||
params.set_n_threads(*n);
|
||||
params.set_print_special(false);
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
let t = Instant::now();
|
||||
state.full(params, &samples).expect("transcribe");
|
||||
let dur = t.elapsed().as_secs_f64();
|
||||
if dur < best { best = dur; }
|
||||
}
|
||||
let rtf = best / audio_secs;
|
||||
let speedup = baseline_dur.map(|b| b / best).unwrap_or(1.0);
|
||||
if baseline_dur.is_none() { baseline_dur = Some(best); }
|
||||
eprintln!("{:>9} | {:>6.2}s | {:>6.3} | {:>6.2}x",
|
||||
n, best, rtf, speedup);
|
||||
}
|
||||
}
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]}}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user