From 535e95c4c0eab7a0c8598f5e7456176f3d1198eb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 12 Aug 2026 17:39:22 +0200 Subject: [PATCH] fix(punktfunk-host): idle-stop the virtual-mic stream so a Windows host can sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mic pump's WASAPI backend rendered silence into the virtual mic's render endpoint (typically the Steam Streaming Microphone) for the whole host lifetime. A RUNNING stream makes the Windows audio stack hold a kernel power request ("An audio stream is currently in use", visible in powercfg /requests), so every idle Punktfunk host blocked system sleep forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since installing punktfunk; powercfg shows the Steam Streaming Microphone"). Stop the render stream (IAudioClient::Stop — client stays initialized, the mic endpoint keeps existing) after 10 s of silence-only output at an unchanged queue length, and park the render thread on a condvar the producer notifies on the empty->non-empty edge, so the next mic frame resumes the stream within one device period — well under the jitter buffer's prime depth, so nothing audible changes. Keying the idle window on the queue LENGTH (not emptiness) also covers a sub-prime tail a vanished client left behind, while any fresh burst moves the length and resets the window instead of being dropped. During a session the box stays awake through the session's own DisplayWakeRequest (pf-frame), never through this silence. PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case a virtual audio driver misbehaves while its render side is paused. --- .../src/audio/windows/wasapi_mic.rs | 124 ++++++++++++++++-- 1 file changed, 114 insertions(+), 10 deletions(-) diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs index e3a091dd..db35914a 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_mic.rs @@ -27,15 +27,28 @@ //! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter //! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI //! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`). +//! +//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack +//! hold a kernel power request ("An audio stream is currently in use", attributed to the +//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is +//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever +//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming +//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the +//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps +//! existing, only the power request is released) and parks on the queue's condvar; the next +//! pushed frame resumes it within one device period. During a session the box is kept awake by +//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence. +//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio +//! driver misbehaves when its render side pauses. use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{sync_channel, SyncSender}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::time::{Duration, Instant}; use wasapi::{Direction, SampleType, StreamMode, WaveFormat}; const CHANNELS: u32 = 2; @@ -60,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN /// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the /// adaptive target drives the ring. const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN; +/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so +/// an idle host releases the audio stack's sleep-blocking power request (see the module docs). +/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar +/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth. +const IDLE_STOP_AFTER: Duration = Duration::from_secs(10); +/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout +/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's +/// `drain_sleep`). +const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250); + +/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies +/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume. +type MicQueue = (Mutex>, Condvar); + +/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the +/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio +/// driver's capture side misbehaves while its render side is paused. +fn mic_always_on() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0")) +} pub struct WasapiVirtualMic { - queue: Arc>>, + queue: Arc, stop: Arc, /// False once the render thread has exited (device error or stop) — the pump's reopen signal. alive: Arc, @@ -94,7 +128,7 @@ impl WasapiVirtualMic { channels == CHANNELS, "virtual mic is stereo-only (got {channels})" ); - let queue = Arc::new(Mutex::new(VecDeque::::new())); + let queue = Arc::new((Mutex::new(VecDeque::::new()), Condvar::new())); let stop = Arc::new(AtomicBool::new(false)); let alive = Arc::new(AtomicBool::new(true)); let ring = Arc::new(RingShared::default()); @@ -144,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic { if !self.alive.load(Ordering::Acquire) { return false; } - let Ok(mut q) = self.queue.lock() else { + let (lock, wake) = &*self.queue; + let Ok(mut q) = lock.lock() else { return false; }; + let was_empty = q.is_empty(); q.reserve(pcm.len() * 4); for &s in pcm { q.extend(s.to_le_bytes()); @@ -171,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic { .overflow .fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed); } + drop(q); + if was_empty { + // Empty→non-empty is the resume moment for an idle-stopped stream (the waiter + // re-checks the queue under the lock, so this cannot be a lost wakeup). + wake.notify_one(); + } true } @@ -179,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic { } fn discard(&self) { - if let Ok(mut q) = self.queue.lock() { + if let Ok(mut q) = self.queue.0.lock() { q.clear(); } } @@ -199,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic { if prime == 0 { return None; // render loop hasn't run yet } - let q = self.queue.lock().ok()?; + let q = self.queue.0.lock().ok()?; Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN)) } @@ -387,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool { } fn render_thread( - queue: Arc>>, + queue: Arc, stop: Arc, shared: Arc, ready: SyncSender>, @@ -461,7 +503,40 @@ fn render_thread( // the target). let mut buf: Vec = Vec::new(); let mut primed = false; - while !stop.load(Ordering::Relaxed) { + // Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and + // `idle_mark` is the queue length last seen while unprimed plus when it was first seen at + // that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle + // verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished + // client left behind, while any genuinely new audio moves the length (a push appends a + // whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue + // can never coincidentally return to its marked length) and restarts the window. + let always_on = mic_always_on(); + let mut running = true; + let mut idle_mark: Option<(usize, Instant)> = None; + 'render: while !stop.load(Ordering::Relaxed) { + if !running { + // Idle-stopped: park on the condvar until mic audio arrives (push notifies on the + // empty→non-empty edge); the timeout only keeps `stop` responsive. + { + let (lock, wake) = &*queue; + let mut q = lock.lock().unwrap(); + while q.is_empty() { + if stop.load(Ordering::Relaxed) { + break 'render; + } + let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap(); + q = guard; + } + } + // A resume failure means the endpoint died while we slept — propagate, so the + // thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death. + audio_client + .start_stream() + .context("resume render stream")?; + running = true; + idle_mark = None; + tracing::debug!("virtual mic stream resumed (client mic audio arrived)"); + } // The device signals when it wants more data; finite timeout keeps `stop` responsive. if h_event.wait_for_event(100).is_err() { continue; @@ -486,7 +561,7 @@ fn render_thread( // Silence base; overwrite with queued mic PCM once the cushion is primed. buf[..need].fill(0); { - let mut q = queue.lock().unwrap(); + let mut q = queue.0.lock().unwrap(); if !primed && q.len() >= prime { primed = true; } @@ -500,6 +575,35 @@ fn render_thread( shared.reprimes.fetch_add(1, Ordering::Relaxed); } } + if primed { + idle_mark = None; + } else if !always_on { + // Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an + // unchanged queue length, stop the stream so the box can sleep. Decided under + // the queue lock, and only when nothing has arrived for the whole window (see + // `idle_mark`'s docs) — a burst landing at the boundary moves the length and + // resets the window instead of being cleared. What IS cleared is ≥10 s stale + // (the pump's stale-gap discard would drop it before the next real frame + // anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above. + match idle_mark { + Some((len, since)) if len == q.len() => { + if since.elapsed() >= IDLE_STOP_AFTER { + q.clear(); + audio_client + .stop_stream() + .context("idle-stop render stream")?; + running = false; + idle_mark = None; + tracing::debug!( + "virtual mic stream idle-stopped (releases the sleep-blocking \ + audio power request; next mic frame resumes it)" + ); + continue 'render; + } + } + _ => idle_mark = Some((q.len(), Instant::now())), + } + } } render_client .write_to_device(space, &buf[..need], None)