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 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..ac696bb 100644 --- a/crates/poltertype-core/src/audio/worker.rs +++ b/crates/poltertype-core/src/audio/worker.rs @@ -8,11 +8,42 @@ 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() { + loop { + // 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,