From f0fe3d9551e3fd6a4e6189c57830a121d3c22db4 Mon Sep 17 00:00:00 2001 From: Dennis Chesey <70021856+dennisgr7@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:15:09 +0100 Subject: [PATCH 1/2] wasapi: don't wake every period while a stream is paused A paused stream's run loop still waited on the WASAPI audio event. With another client keeping the endpoint running, that event can keep being signalled every engine period after IAudioClient::Stop, so the stream thread woke about 100 times a second with nothing to render. While stopped, the loop now waits only for commands and device-change events, and maps the signalled index back to the usual handle layout. Priming returns before the wait, so it is unaffected. Measured on cpal 0.18.2 with Spotifast on Windows 11 ARM64 (Snapdragon X), paused in the tray with a browser playing on the same device: the app's wake-ups fell from 117-132 to 17-22 a second, with playback unchanged. --- CHANGELOG.md | 1 + src/host/wasapi/stream.rs | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb428dd03..abb9af808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **WASAPI**: Output streams now start with real audio immediately instead of undefined content in the render buffer. - **WASAPI**: A stream paused immediately after starting no longer plays silence before real audio on resume. - **WASAPI**: Fix `I64` and `F64` incorrectly reported as supported output formats. +- **WASAPI**: A paused stream no longer wakes its thread every engine period while another client keeps the device running. ## [0.18.2] - 2026-08-16 diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index a7723e7f3..ab3630589 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -849,8 +849,28 @@ fn process_commands_and_await_signal( } } - // Wait for any of the handles to be signalled. - let handle_idx = match wait_for_handle_signal(&run_context.handles) { + // Wait for any of the handles to be signalled. A stopped client's audio + // event can still be signalled every engine period while other streams + // keep the endpoint running, which would wake this thread about 100 + // times a second for nothing. While paused, wait only for commands and + // device changes. + let playing = run_context.stream.playback_state == PlaybackState::Playing; + let paused_handles: Vec; + let handles: &[Foundation::HANDLE] = if playing { + &run_context.handles + } else { + paused_handles = run_context + .handles + .iter() + .enumerate() + .filter(|(index, _)| *index != 1) + .map(|(_, handle)| *handle) + .collect(); + &paused_handles + }; + let handle_idx = match wait_for_handle_signal(handles) { + // Back to the layout below: the audio event (1) was left out. + Ok(idx) if !playing && idx >= 1 => idx + 1, Ok(idx) => idx, Err(err) => { emit_error(error_callback, err); From c0d2d9f65c5b96715c5f44623a8ce50b85f0c1d1 Mon Sep 17 00:00:00 2001 From: Dennis Chesey <70021856+dennisgr7@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:13:22 +0100 Subject: [PATCH 2/2] wasapi: wait on a subslice of the handles while paused Put the stream's audio event last among the handles the run loop waits on, after the command and default-device change events. A paused stream then waits on all but the last handle instead of collecting a new Vec, and the signalled index needs no mapping back. The layout comments implied more than three handles and still referred to a list of voices; there is at most one handle of each kind. --- src/host/wasapi/stream.rs | 55 ++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/src/host/wasapi/stream.rs b/src/host/wasapi/stream.rs index ab3630589..4df0e132c 100644 --- a/src/host/wasapi/stream.rs +++ b/src/host/wasapi/stream.rs @@ -241,8 +241,11 @@ struct RunContext { // Streams that have been created in this event loop. stream: StreamInner, - // Handles corresponding to the `event` field of each element of `voices`. Must always be in - // sync with `voices`, except that the first element is always `pending_scheduled_event`. + // The handles the run loop waits on, in this order: `pending_scheduled_event` (commands), + // the default-device change event (only for streams that track the default device), and + // the stream's audio event last, so that a paused stream can wait on all but the last. + // When several are signalled, the wait reports the lowest index; an auto-reset event it + // does not report stays signalled for the next wait. handles: Vec, commands: Receiver, @@ -347,10 +350,11 @@ impl Stream { debug_assert_ne!(qpc_frequency, 0, "QueryPerformanceFrequency returned zero"); } - let mut handles = vec![pending_scheduled_event, stream_inner.event]; + let mut handles = vec![pending_scheduled_event]; if let Some(ref monitor) = default_device_monitor { handles.push(monitor.event); } + handles.push(stream_inner.event); let pending_device_changed = default_device_monitor .as_ref() @@ -423,10 +427,11 @@ impl Stream { debug_assert_ne!(qpc_frequency, 0, "QueryPerformanceFrequency returned zero"); } - let mut handles = vec![pending_scheduled_event, stream_inner.event]; + let mut handles = vec![pending_scheduled_event]; if let Some(ref monitor) = default_device_monitor { handles.push(monitor.event); } + handles.push(stream_inner.event); let pending_device_changed = default_device_monitor .as_ref() @@ -852,25 +857,15 @@ fn process_commands_and_await_signal( // Wait for any of the handles to be signalled. A stopped client's audio // event can still be signalled every engine period while other streams // keep the endpoint running, which would wake this thread about 100 - // times a second for nothing. While paused, wait only for commands and - // device changes. - let playing = run_context.stream.playback_state == PlaybackState::Playing; - let paused_handles: Vec; - let handles: &[Foundation::HANDLE] = if playing { - &run_context.handles + // times a second for nothing. While paused, leave out the audio event, + // which is the last handle. + let audio_idx = run_context.handles.len() - 1; + let handles = if run_context.stream.playback_state == PlaybackState::Playing { + &run_context.handles[..] } else { - paused_handles = run_context - .handles - .iter() - .enumerate() - .filter(|(index, _)| *index != 1) - .map(|(_, handle)| *handle) - .collect(); - &paused_handles + &run_context.handles[..audio_idx] }; let handle_idx = match wait_for_handle_signal(handles) { - // Back to the layout below: the audio event (1) was left out. - Ok(idx) if !playing && idx >= 1 => idx + 1, Ok(idx) => idx, Err(err) => { emit_error(error_callback, err); @@ -878,18 +873,20 @@ fn process_commands_and_await_signal( } }; - // Handle layout: 0 = pending_scheduled_event (commands), 1 = WASAPI audio event, - // 2+ = default-device change event (only present for default-device streams). + // Handle layout: see `RunContext::handles`. // Continue(true) = audio event fired, proceed to process audio this iteration. // Continue(false) = command or device-change event, loop around and wait again. - if handle_idx >= 2 { - emit_error( - error_callback, - default_device_change_error(run_context.default_device_flow), - ); - return ControlFlow::Continue(false); + match handle_idx { + 0 => ControlFlow::Continue(false), + idx if idx == audio_idx => ControlFlow::Continue(true), + _ => { + emit_error( + error_callback, + default_device_change_error(run_context.default_device_flow), + ); + ControlFlow::Continue(false) + } } - ControlFlow::Continue(handle_idx != 0) } // The loop for processing pending input data.