From e7910bccbee7600102c79e28349a6d6912560c28 Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 18:36:58 +0200 Subject: [PATCH 1/3] =?UTF-8?q?audio:=20release=20the=20cached=20output=20?= =?UTF-8?q?stream=20when=20idle=20=E2=80=94=20HDMI=20blocked=20macOS=20sle?= =?UTF-8?q?ep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker cached its OutputStream for the life of the process once a sound had played: the 'stale refresh' in handle() only dropped it when another play arrived after the idle window, and with no further plays the stream stayed open forever. On macOS an open CoreAudio output on an HDMI / DisplayPort device keeps coreaudiod's power assertion alive, so the system would neither turn the display off nor sleep. The worker loop now waits with recv_timeout(STREAM_IDLE_REFRESH) and releases the cached stream on timeout; the next play reopens it lazily (~20-50 ms, hidden under the synth lead silence). --- crates/poltertype-core/src/audio/consts.rs | 7 ++++-- crates/poltertype-core/src/audio/mod.rs | 8 ++++++ crates/poltertype-core/src/audio/worker.rs | 29 ++++++++++++++++------ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/poltertype-core/src/audio/consts.rs b/crates/poltertype-core/src/audio/consts.rs index 6992a55..c84c03e 100644 --- a/crates/poltertype-core/src/audio/consts.rs +++ b/crates/poltertype-core/src/audio/consts.rs @@ -2,8 +2,11 @@ use std::time::Duration; -/// Drop the cached `OutputStream` after this much idle time so the -/// next play picks up the (possibly changed) default audio device. +/// Drop the cached `OutputStream` after this much idle time. Two +/// reasons: the next play picks up the (possibly changed) default +/// audio device, and — critically on macOS with HDMI output — an +/// idle open stream otherwise keeps coreaudiod's power assertion +/// alive and blocks display/system sleep. /// 30 s is well above any plausible "pause-resume burst" cadence, /// so rapid hotkey use stays on the warm cached stream. pub(crate) const STREAM_IDLE_REFRESH: Duration = Duration::from_secs(30); diff --git a/crates/poltertype-core/src/audio/mod.rs b/crates/poltertype-core/src/audio/mod.rs index 1750ac4..352f6e4 100644 --- a/crates/poltertype-core/src/audio/mod.rs +++ b/crates/poltertype-core/src/audio/mod.rs @@ -26,6 +26,14 @@ //! these handle "user just plugged in headphones" gracefully without //! paying the per-play cost during normal pause / resume bursts. //! +//! The cached stream is also **released outright** after +//! [`STREAM_IDLE_REFRESH`] with no commands. A permanently open +//! CoreAudio output on an HDMI / DisplayPort device keeps +//! coreaudiod's power assertion alive, which on macOS blocks display +//! and system sleep ("app holds the audio focus" symptom). Letting +//! go of the stream between plays costs one ~20-50 ms reopen, hidden +//! under the synth's lead silence. +//! //! Themes live in `/sound-themes//.ogg`. //! Missing files are silent — we never crash because audio is absent. diff --git a/crates/poltertype-core/src/audio/worker.rs b/crates/poltertype-core/src/audio/worker.rs index d827236..0148e62 100644 --- a/crates/poltertype-core/src/audio/worker.rs +++ b/crates/poltertype-core/src/audio/worker.rs @@ -8,24 +8,39 @@ use std::io::BufReader; use tracing::{debug, info, warn}; pub(crate) fn run_worker(rx: crossbeam_channel::Receiver) { - info!("audio worker started (cached OutputStream + idle refresh)"); + use crossbeam_channel::RecvTimeoutError; + + info!("audio worker started (cached OutputStream + idle release)"); let mut state = WorkerState::new(); - while let Ok(cmd) = rx.recv() { - match cmd { - AudioCmd::Refresh { + loop { + match rx.recv_timeout(STREAM_IDLE_REFRESH) { + Ok(AudioCmd::Refresh { theme_dir: d, volume: v, - } => { + }) => { state.theme_dir = d; state.volume = v; debug!(theme_dir = ?state.theme_dir, volume = state.volume, "audio refreshed"); } - AudioCmd::Play(event) => { + Ok(AudioCmd::Play(event)) => { play_event(&mut state, event); } - AudioCmd::Shutdown => break, + Ok(AudioCmd::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => { + // No plays for a whole refresh window — release the + // stream. A long-lived open CoreAudio output on an + // HDMI / DisplayPort device keeps coreaudiod's power + // assertion alive, which on macOS blocks display + // sleep and system sleep. Dropping the stream hands + // the device back and costs only a ~20-50 ms reopen + // on the next sound (cushioned by LEAD_SILENCE_MS). + if state.stream.is_some() { + debug!("audio: idle timeout — releasing output stream"); + state.invalidate(); + } + } } } info!("audio worker stopped"); From 3da093470850ce7a66ced4be14cbb6f3f2e010bd Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Wed, 5 Aug 2026 18:37:12 +0200 Subject: [PATCH 2/3] =?UTF-8?q?changelog:=20HDMI=20audio=20stream=20blocke?= =?UTF-8?q?d=20macOS=20sleep=20=E2=80=94=20unreleased=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5197964..8ebbe26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to PolterType are recorded here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### macOS + +- **PolterType no longer blocks display / system sleep when the sound + output is HDMI (or DisplayPort).** The audio worker cached its + CoreAudio `OutputStream` for the whole life of the process once a + sound had played, and an open output stream on an HDMI device keeps + coreaudiod's power assertion alive — macOS then refuses to turn the + screen off or sleep, as if audio were playing forever. The worker + now releases the stream after 30 s without a sound command (the + existing `STREAM_IDLE_REFRESH` window) and reopens it lazily on the + next play; the ~20-50 ms reopen cost is hidden under the synth + tone's lead silence. + ## [0.12.0] — the AI socket ships in the box ### Changed @@ -62,7 +77,6 @@ and the project follows [Semantic Versioning](https://semver.org/). quotes. Still no automatic restart — a plug-in that crashes on startup would become a fork bomb, and the failure would go back to being invisible. - ## [0.11.0] — plug-ins that run, and the first release Windows was actually held to Two blocks, and they meet in one place: the plug-in system landed in From 3592ac610cb905e808fad0aa574fa01738264868 Mon Sep 17 00:00:00 2001 From: Redderick Shohart Date: Thu, 6 Aug 2026 14:28:40 +0200 Subject: [PATCH 3/3] audio: don't wake the idle worker when there is no stream to release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing review on Just-Code-NET/PolterType#23: waiting with recv_timeout unconditionally woke the worker every 30 s for the life of the process even when no stream was cached — ~2880 no-op wakeups a day for a tray app. Block with recv() while state.stream is None and only poll on a timeout while a stream is cached. --- crates/poltertype-core/src/audio/worker.rs | 52 ++++++++++++++-------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/crates/poltertype-core/src/audio/worker.rs b/crates/poltertype-core/src/audio/worker.rs index 0148e62..ac696bb 100644 --- a/crates/poltertype-core/src/audio/worker.rs +++ b/crates/poltertype-core/src/audio/worker.rs @@ -15,32 +15,48 @@ pub(crate) fn run_worker(rx: crossbeam_channel::Receiver) { let mut state = WorkerState::new(); loop { - match rx.recv_timeout(STREAM_IDLE_REFRESH) { - Ok(AudioCmd::Refresh { + // Block indefinitely while there is no stream to release; + // only poll on a timeout while a stream is cached. Waiting + // with `recv_timeout` unconditionally would wake the worker + // every 30 s for the life of the process just to discover + // there is nothing to drop. + let cmd = if state.stream.is_some() { + match rx.recv_timeout(STREAM_IDLE_REFRESH) { + Ok(cmd) => cmd, + Err(RecvTimeoutError::Timeout) => { + // No plays for a whole refresh window — release + // the stream. A long-lived open CoreAudio output + // on an HDMI / DisplayPort device keeps + // coreaudiod's power assertion alive, which on + // macOS blocks display sleep and system sleep. + // Dropping the stream hands the device back and + // costs only a ~20-50 ms reopen on the next sound + // (cushioned by LEAD_SILENCE_MS). + debug!("audio: idle timeout — releasing output stream"); + state.invalidate(); + continue; + } + Err(RecvTimeoutError::Disconnected) => break, + } + } else { + match rx.recv() { + Ok(cmd) => cmd, + Err(_) => break, + } + }; + match cmd { + AudioCmd::Refresh { theme_dir: d, volume: v, - }) => { + } => { state.theme_dir = d; state.volume = v; debug!(theme_dir = ?state.theme_dir, volume = state.volume, "audio refreshed"); } - Ok(AudioCmd::Play(event)) => { + AudioCmd::Play(event) => { play_event(&mut state, event); } - Ok(AudioCmd::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => { - // No plays for a whole refresh window — release the - // stream. A long-lived open CoreAudio output on an - // HDMI / DisplayPort device keeps coreaudiod's power - // assertion alive, which on macOS blocks display - // sleep and system sleep. Dropping the stream hands - // the device back and costs only a ~20-50 ms reopen - // on the next sound (cushioned by LEAD_SILENCE_MS). - if state.stream.is_some() { - debug!("audio: idle timeout — releasing output stream"); - state.invalidate(); - } - } + AudioCmd::Shutdown => break, } } info!("audio worker stopped");