fix(audio): Windows playback stutter under GPU load (#334) (#426)

* fix(audio): promote WASAPI render thread to MMCSS Pro Audio on Windows

Wraps the outermost audio source in a `PriorityBoostSource` that calls
`AvSetMmThreadCharacteristicsW("Pro Audio")` on its first sample. The
cpal output-stream callback runs `Source::next` on the WASAPI render
thread, which is otherwise normal-priority and gets preempted under
WebView2 / DWM / GPU pressure — producing the audible click/stutter
reported in issue #334. No-op on Linux/macOS (PipeWire/rtkit and
CoreAudio promote their audio threads externally).


* fix(build): repair Windows compile after audio split + lib decompose

Two pre-existing build breakers on Windows that surfaced after the
`use super::*;` cleanup (9cc74a7) and the lib-decompose refactor
(cfeec22):

- `audio/device_watcher.rs` lost the `output_enumeration_includes_pinned`
  import. Add it cfg-gated to `not(target_os = "linux")` since it's only
  called inside the non-Linux pinned-device fallback path.
- `lib_commands/sync/tray.rs::is_tiling_wm()` is `#[cfg(target_os = "linux")]`,
  but its Tauri command wrapper `is_tiling_wm_cmd()` is unconditional.
  Add a `#[cfg(not(target_os = "linux"))]` stub returning `false`.

No behavior change on Linux. Restores `cargo build` on Windows + macOS.

* perf(ui): pause cosmetic animations when window loses OS focus

Companion to the WASAPI MMCSS fix in this branch — issue #334
reported audible audio stutter on low-end laptops, partly traced
to WebView2 still compositing CSS animations + waveform canvas at
full rate while the user has alt-tabbed into another app.

Existing `data-app-hidden` / `data-psy-native-hidden` flags pause
animations only when the window is fully hidden (minimized or
behind a tray). When the window is visible-but-unfocused — the
common "music in the background while I work" case — every infinite
keyframe + 60fps rAF loop keeps running.

Add a parallel `data-app-blurred` flag driven by `window.focus`/
`window.blur`, plus matching `__psyBlurred` on the global window
object for imperative checks. The CSS rule that pauses every
`animation-play-state` on hidden gets a third selector for blurred.
WaveformSeek's two 60fps rAF loops (animated styles + settings demo)
extend their existing `document.hidden || __psyHidden` short-circuit
to also include the new blurred flag, falling back to the same
400ms polling path that already exists for hidden.

Verified visually on the dashboard: alt-tab → `<html
data-app-blurred="true">`, mesh blobs in the fullscreen player +
marquee + waveform 60fps loop all pause. Click back into the
window → blob/marquee/waveform resume immediately.

Cross-platform: focus/blur events fire reliably on all targets, so
the optimization applies to Windows, Linux (incl. WebKitGTK with
software compositing where the savings are largest), and macOS.

* chore: remove stale app-icon.png from repo root

* perf(ui): add 'Reduce animations' toggle that caps animated seekbar styles to 30 fps

Off by default. When on, animated seekbar styles (pulsewave, particletrail,
liquidfill, retrotape) skip every other rAF and advance the animation timer by
a doubled delta, so wave speed is unchanged while draws are halved. Aimed at
low-end Windows GPUs where the 60 fps shadow-blur loop drives ~40 % WebView2
GPU usage even when focused.

Toggle lives directly under the seekbar style picker in Settings → Appearance.
i18n in all 8 supported languages.
This commit is contained in:
Frank Stellmacher
2026-05-02 21:10:32 +02:00
committed by GitHub
parent 7064ca500e
commit 2e9618cf54
22 changed files with 168 additions and 9 deletions
+2 -1
View File
@@ -1638,12 +1638,13 @@ pub async fn audio_play_radio(
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
let counting = CountingSource::new(notifying, state.samples_played.clone());
let boosted = PriorityBoostSource::new(counting);
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(counting);
sink.append(boosted);
{
let mut cur = state.current.lock().unwrap();
+5 -3
View File
@@ -519,7 +519,7 @@ pub(crate) fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
pub(crate) struct BuiltSource {
pub(crate) source: CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>,
pub(crate) source: PriorityBoostSource<CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>>,
pub(crate) duration_secs: f64,
pub(crate) output_rate: u32,
pub(crate) output_channels: u16,
@@ -611,9 +611,10 @@ pub(crate) fn build_source(
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
let counting = CountingSource::new(notifying, sample_counter);
let boosted = PriorityBoostSource::new(counting);
Ok(BuiltSource {
source: counting,
source: boosted,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
@@ -670,9 +671,10 @@ pub(crate) fn build_streaming_source(
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
let counting = CountingSource::new(notifying, sample_counter);
let boosted = PriorityBoostSource::new(counting);
Ok(BuiltSource {
source: counting,
source: boosted,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
+2
View File
@@ -5,6 +5,8 @@ use std::time::Duration;
use tauri::Emitter;
use super::engine::AudioEngine;
#[cfg(not(target_os = "linux"))]
use super::dev_io::output_enumeration_includes_pinned;
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let reopen_tx = engine.stream_reopen_tx.clone();
+2
View File
@@ -9,6 +9,7 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
use super::sources::PriorityBoostSource;
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -189,6 +190,7 @@ pub async fn audio_preview_play(
}
let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0));
let source = source.take_duration(dur);
let source = PriorityBoostSource::new(source);
// ── Build secondary sink on the existing OutputStream ────────────────────
let sink = Arc::new(
+80
View File
@@ -399,3 +399,83 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
result
}
}
// ─── PriorityBoostSource — promote the calling thread on first sample ────────
//
// rodio's `Sink` runs `Source::next` inside the cpal output-stream callback.
// On Windows that callback is the WASAPI render thread, which by default has
// only normal priority — when WebView2 / DWM / GPU work spikes the system,
// the audio thread gets preempted and underruns produce audible click /
// stutter. This wrapper sets the MMCSS "Pro Audio" task class on the first
// `next()` call so the kernel keeps the render thread on a real-time class
// alongside other audio applications. On Linux/macOS the wrapper compiles to
// a no-op — those platforms already promote their audio threads externally
// (PipeWire/rtkit, CoreAudio).
//
// Idempotent across track changes: each new track instantiates a fresh
// PriorityBoostSource, but `AvSetMmThreadCharacteristicsW` can be called
// repeatedly on the same thread.
#[cfg(target_os = "windows")]
fn promote_thread_to_pro_audio() {
use std::sync::atomic::{AtomicBool, Ordering};
use windows::core::PCWSTR;
use windows::Win32::System::Threading::AvSetMmThreadCharacteristicsW;
static LOGGED: AtomicBool = AtomicBool::new(false);
// Null-terminated UTF-16 task name, lifetime-pinned for the call.
let task: [u16; 10] = [
b'P' as u16, b'r' as u16, b'o' as u16, b' ' as u16,
b'A' as u16, b'u' as u16, b'd' as u16, b'i' as u16,
b'o' as u16, 0,
];
let mut idx: u32 = 0;
let result = unsafe { AvSetMmThreadCharacteristicsW(PCWSTR(task.as_ptr()), &mut idx) };
if result.is_ok() && !LOGGED.swap(true, Ordering::Relaxed) {
// First-time log: not in the hot path on subsequent track starts.
// Logging is file IO (blocking) but we only run it once per process
// lifetime, on the very first render-callback invocation.
crate::app_eprintln!("[psysonic] WASAPI render thread promoted to MMCSS \"Pro Audio\"");
}
// Handle leaks intentionally — promotion lasts until the thread exits,
// which matches the WASAPI render-thread lifetime.
}
#[cfg(not(target_os = "windows"))]
#[inline(always)]
fn promote_thread_to_pro_audio() {}
pub(crate) struct PriorityBoostSource<S: Source<Item = f32>> {
inner: S,
promoted: bool,
}
impl<S: Source<Item = f32>> PriorityBoostSource<S> {
pub(crate) fn new(inner: S) -> Self {
Self { inner, promoted: false }
}
}
impl<S: Source<Item = f32>> Iterator for PriorityBoostSource<S> {
type Item = f32;
#[inline]
fn next(&mut self) -> Option<f32> {
if !self.promoted {
self.promoted = true;
promote_thread_to_pro_audio();
}
self.inner.next()
}
}
impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos)
}
}
+5
View File
@@ -390,6 +390,11 @@ pub(crate) fn no_compositing_mode() -> bool {
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn is_tiling_wm() -> bool {
false
}
/// Tauri command: lets the frontend know whether we're running under a tiling
/// WM so it can decide whether to render the custom TitleBar component.
#[tauri::command]