Repository navigation
Windows: headphones/output device powers off during recording #724
Description
Description
On Windows, my headphones (output device) turn off partway through a recording, even though I'm actively speaking into the microphone the whole time.
Root cause (traced through the code)
Windows audio capture lives in electron/native/wgc-capture/src/wasapi_loopback_capture.{h,cpp}. Both the microphone and system-audio paths open WASAPI capture streams:
- Mic:
WasapiLoopbackCapture::initializeMicrophone()→initialize(WasapiCaptureEndpoint::Microphone, ...)(wasapi_loopback_capture.cpp:222-265) opens a plain capture-modeIAudioClienton the default capture endpoint. - System audio: same
initialize(), but forWasapiCaptureEndpoint::SystemLoopbackit resolves the render endpoint (the headphones/speakers) and initializes it withAUDCLNT_STREAMFLAGS_LOOPBACK(wasapi_loopback_capture.cpp:247-255) — this reads what's already playing, it does not write to the device.
There is no IAudioRenderClient anywhere in the codebase, and no mic→speaker monitoring/passthrough. So the app never writes any PCM to the output device during a recording — it only ever reads from it via loopback capture.
Because the app never opens a render/playback stream on the output device, the render endpoint may look idle to Windows throughout the recording, even while mic capture and loopback capture are both running. That likely lets one of Windows' own power/priority controls on the device kick in and shut it off mid-recording.
I haven't pinned down which exact Windows setting is responsible — it's one of these (need to verify against the code path above):
- Device Manager → the device → Power Management tab → "Allow the computer to turn off this device to save power" (may also show as "Continue running on battery power" vs. "Disable automatically to save power" depending on Windows version/device).
- Sound settings → the device → Advanced/Properties → Exclusive Mode → "Allow applications to take exclusive control of this device" / "Give exclusive mode applications priority" — if some other app or the OS takes exclusive control, other endpoints can get suspended.
This is unrelated to the existing Linux/PipeWire "wrong headphone jack" device-selection bug referenced elsewhere in the code (#404) — that's about picking the wrong input device, not this Windows output-power/priority issue.
Workaround for now
Try disabling power-saving on the device in Device Manager (Power Management tab), and/or disabling exclusive-mode priority for other apps in the device's Advanced properties in Sound settings.
Proposed fix
Open a silent (or near-silent) WASAPI render stream on the same output device for the duration of the recording, alongside the existing loopback capture. Continuously writing to it — even silence — should keep Windows treating the endpoint as actively in use, regardless of which exact setting is currently causing the drop.
Environment
- Windows
- Recording with microphone enabled
I'd like to work on this — can I pick it up?
Activity
EtienneLescot commentedon Sep 20, 2026
@abduznik of course you can pick it up, go ahead ;-)
Thanks!
EtienneLescot commentedon Sep 20, 2026
Thanks for the detailed write-up — the code reading checks out. I verified it:
initialize()is the only WASAPI path,AUDCLNT_STREAMFLAGS_LOOPBACKis set only forSystemLoopback(wasapi_loopback_capture.cpp:241-249), and there is noIAudioRenderClientanywhere. With mic only — the default config, since both toggles startfalse(useScreenRecorder.ts:237,242) — there is genuinely no stream of any kind on the render endpoint for the whole take.The direction is right and has precedent: NVDA merged "Play silence to keep audio device awake" (nvaccess/nvda#16099, fixes #14386) with exactly this design, and soundkeeper / keep-audio-alive exist for this. Use
IAudioRenderClient::ReleaseBuffer(frames, AUDCLNT_BUFFERFLAGS_SILENT)— the audio engine treats the packet as silence regardless of the data values, so there is nothing to memset.Before the code, though, we need the mechanism. Three candidates give three different answers, and "silent stream" only fixes two of them: USB selective suspend (device level), audio-engine/endpoint idle (endpoint level), or the headset's own firmware auto-power-off. If it is the third, silence may not count as "playing" on some headsets, and the fix would not help. Could you answer these three?
DEVICE_STATE_NOTPRESENT), or stay present and simply go quiet?Instrumenting the helper with
IMMNotificationClient(OnDeviceStateChanged/OnDefaultDeviceChanged) or aGetDeviceStatepoll behind an env flag turns "the device powers off" into a timestamped event correlated with the take — that is the evidence the PR needs. Worth knowing on our side:technical-documentation/architecture/recording.md:72already records that WASAPI loopback delivers no packets while nothing is playing, which is consistent with your theory.Three things to build in from the start:
main.cpphas five to six stop sites (1366, 1380-1381, 1411-1412, 1454-1455, 1586-1589) — add a single helper rather than a third call at each one, and register it as a named[stop-timing]step, since that is how that file's shutdown gets diagnosed.Initialize(SHARED)failing because another app holds the device exclusively must be non-fatal, and an endpoint invalidated mid-take must not end the recording. AOPENSCREEN_WGC_DISABLE_AUDIO_KEEPALIVE=1kill-switch (same convention asOPENSCREEN_WGC_ENABLE_DXGI_INPUT) is what lets you A/B this on your own machine and produce before/after evidence.captureLoopprints to stderr and exits, andAudioMixer::mixLoopfills silence on the clock forever — the track is dead from that instant on and nothing tells the user. That is the same class of failure as fix(recording): advance the audio timeline on the clock, not on the data #406. Please emit{"event":"warning","code":"audio-device-lost",...}following themicrophone-defaulted/webcam-unavailablepattern (nativeWindowsCaptureStop.ts:108,121, the tests innativeWindowsCaptureStop.test.ts, and the toast inuseScreenRecorder.ts:1204-1208). Note those are read at start (handlers.ts:2674-2680), so say whether you read yours at stop or add a live notice. This also matters because it is the only part of the change a maintainer without your headset can verify: unplug the device mid-recording and watch for the notice.Verification — CI is Linux-only, so none of this can be covered there:
npm run test:wgc-mic:win,test:wgc-mixed-audio:win,test:wgc-audio-timeline:win.scripts/test-windows-*.mjsshaped liketest-windows-audio-timeline.mjs, playing tones at known instants, is the way to prove the mic track is continuous and undamaged. Silence proves nothing here — the mixer fills a dead source with silence, so only missing tones are evidence.npm run build:native:winbefore testing:electron/native/bin/win32-x64/is frozen at whenever someone last built it and no normal build refreshes it, so an un-rebuilt dev run exercises the old code path.Sequencing I would suggest: (1) instrumentation + the identified mechanism, (2) the keep-alive with its kill-switch and lifecycle, (3) the
audio-device-lostwarning. One honest caveat up front: if the mechanism turns out to be the headset's own firmware timer, the right end to this issue may be a troubleshooting note rather than code. Windows only — please leave macOS and Linux alone, their audio paths are separate.abduznik commentedon Sep 20, 2026
Thanks for the thorough review — answers to your three questions, from my own hardware (Corsair headset connected via wireless USB dongle):
Given mic and headphones share one physical device behind one dongle, I think this points more toward USB selective suspend on the dongle (or the headset's own idle/auto-off firmware) rather than a separate render-endpoint-only power state — since if it were purely "render endpoint looks idle," I'd expect the device to stay enumerated and just mute output, not fully disconnect with the audible drop sound. I haven't yet ruled out the firmware-timer possibility you flagged, which the silent-keep-alive approach wouldn't fix.
Given that, happy to follow your suggested sequencing: instrumentation first (to get a real timestamped signal on why the device state changes) before committing to the silent-stream fix, since a full disconnect leans away from "pure endpoint idle" and toward either USB selective suspend or firmware auto-off. I'll hold off on further code until we've nailed down the mechanism — will report back with
IMMNotificationClient/device-state instrumentation results.abduznik commentedon Sep 21, 2026
Real evidence from the device watcher — and it's a genuinely useful negative result.
Setup: Corsair Void Wireless v2 (headset+mic on the same wireless dongle), using the standalone diagnostic tool against the CI-built helper (
wgc-capture.exefrom PR #726),--miconly (no--system-audio),OPENSCREEN_WGC_LOG_AUDIO_DEVICE_EVENTS=1.WasapiDeviceWatcherrecorded nothing at all beyond the twobaselineevents at t=0. Nostate-changed, noremoved, nodefault-changed, for the entire 10-minute session.IMMNotificationClientnever fired.Full JSON: [attaching / can paste helperStdout if useful — it's short, only 9 lines of JSON events total across the whole 10-minute run]
The recording itself was unaffected — clean 17986 frames, exit code 0, normal stop-timing. So whatever happened, it didn't touch the WASAPI audio-engine layer enough to fail the capture or trip a device-state notification.
What this rules out, I think: both
DEVICE_STATE_NOTPRESENT/DEVICE_STATE_UNPLUGGED(USB-level disappearance) and any WASAPI-visible state transition on the render or capture endpoint. If the OS had un-enumerated the device or even flagged it disabled,OnDeviceStateChanged/OnDeviceRemovedshould have fired — they didn't.What I think this points to instead: the wireless RF link between the dongle and the earcups dropping (probably the headset's own idle/auto-off timer on that link), while the USB dongle itself stays enumerated and "active" to Windows the whole time. That would explain the audible disconnect chime with zero WASAPI endpoint-state signal — from Windows' audio stack's point of view, nothing happened; the silence would be entirely on the RF layer between dongle and earcups, invisible to
IMMNotificationClient.If that's right, a software-side keep-alive (silent render stream, or anything else operating at the WASAPI layer) wouldn't fix this — it's the same "headset firmware auto-off" case you flagged as possibly out of reach. I don't have an easy way to instrument the RF link itself from this codebase.
Given that, I'm inclined to say the honest conclusion here is a documentation/troubleshooting note (mentioning USB selective suspend + the headset's own wireless power-saving settings, if the manufacturer's software like iCUE exposes one) rather than an app-side code fix — happy to be pointed at something I'm missing, but wanted to bring the real data back before writing more code, per your original ask.
abduznik commentedon Sep 21, 2026
Correction to my last comment — ran one more test and it changes the conclusion.
Same setup, same headset, same diagnostic tool, 10-minute runs:
--miconly (system audio off): headset disconnects at ~7-10 min. Confirmed twice.--mic --system-audio(loopback capture active on the render endpoint): headset stayed connected for the full 10 minutes. No disconnect.WasapiDeviceWatcherlogged nothing beyond baseline in either case — so whatever Windows is doing here isn't aDEVICE_STATE_*transitionIMMNotificationClientcan see. But the loopback-on/loopback-off comparison is a clean, repeatable signal: touching the render endpoint at all (even read-only, viaAUDCLNT_STREAMFLAGS_LOOPBACK) is enough to prevent the drop that happens when nothing touches it.That undoes my "might be RF-link/firmware, not fixable in software" theory from the last comment — it now looks like exactly the render-endpoint-idle mechanism the original issue proposed, just not one that surfaces through
IMMNotificationClient's discrete state events. A silent WASAPI render stream (the original keep-alive idea) should plausibly fix the mic-only case the same way loopback capture already does.Proposing to go ahead with that now: a render stream writing
AUDCLNT_BUFFERFLAGS_SILENTpackets, independent ofcaptureSystemAudio, gated behind a kill-switch (OPENSCREEN_WGC_DISABLE_AUDIO_KEEPALIVE=1per your naming suggestion), with the single-stop-helper lifecycle and non-fatalInitialize(SHARED)failure handling you outlined. I'll validate it against the same mic-only 10-minute repro before opening it for review, and keep theWasapiDeviceWatcherinstrumentation in since it's zero-cost when disabled and useful if this surfaces again on different hardware. Will hold off on theaudio-device-lostwarning until there's a real state-change event to hang it on, unless you'd rather have it regardless as a safety net.fix(windows): keep the render endpoint alive during recording
abduznik commentedon Sep 21, 2026
Fix implemented and validated on real hardware — it works.
Pushed
WasapiRenderKeepAliveto PR #726: opens the default render endpoint in shared mode, primes + continuously writesAUDCLNT_BUFFERFLAGS_SILENTpackets for the duration of the recording, independent ofcaptureSystemAudio. On by default,OPENSCREEN_WGC_DISABLE_AUDIO_KEEPALIVE=1kill-switch per your naming suggestion. Single stop-helper lambda mirroring the device-watcher pattern, wired into all 5 stop sites plus the[stop-timing]structured log as its own named step (render-keepalive).Test: same standalone diagnostic tool, same mic-only repro that reliably dropped the headset twice before (600s,
--mic, no--system-audio) — headset stayed connected for the full 10 minutes. Clean recording throughout (17984 frames, exit code 0), and the newrender-keepalivestop step completed normally at 68ms with no error.CI (PR #726) also confirms it compiles cleanly on Windows:
Windows x64 diagnostic bundleandRust check (Windows compositor)both pass.Given
WasapiDeviceWatchernever caught a state-change event even while the drop was happening, I'm leaving theaudio-device-lostwarning out for now as you suggested (no real state-change event to hang it on) — happy to add it as a belt-and-suspenders measure if you'd rather have it regardless.Remaining from your original checklist:
test-windows-audio-timeline-style tone test to prove the mic track stays continuous and undamaged with the keep-alive running (silence proves nothing, per your note), plus a check that the system-audio track stays digital silence with both loopback and the keep-alive writing into the same endpoint. Will get to those next unless you'd rather see the PR opened for review as-is first.abduznik commentedon Sep 21, 2026
Honest update: the fix does not appear to reliably solve this, and I want to flag it before this goes further in review.
After the CodeRabbit fixes landed (nonblocking callbacks, CoInitializeEx), I re-tested with the same mic-only repro at longer durations and got an inconsistent result:
That 630-640s mark lands right in the same 7-10 minute window the original, unfixed build was already dropping at. That's a strong signal the render keep-alive isn't actually preventing anything — the earlier 600s "success" was very likely a fluke (another process incidentally keeping the render endpoint busy), not the fix working.
So where this leaves things: the mic+system-audio comparison from before (loopback capture prevents the drop, mic-only doesn't) still looks like a real, repeatable effect. But "write silence to the render endpoint ourselves" isn't reproducing that same protection the way I expected — which suggests either:
I don't think this PR should proceed toward merge as a fix yet. Options I see:
WasapiDeviceWatcher(still correctly built, useful, doesn't claim to fix anything) and pullWasapiRenderKeepAliveback out until it's actually proven under controlled conditions.Sorry for the back-and-forth — wanted to bring this to you as soon as I saw it rather than let a review proceed on a fix that might not hold up. Will re-run the loopback-vs-mic-only comparison cleanly next and report back.
abduznik commentedon Sep 21, 2026
Found it — this is not fixable from inside openscreen, and it's not even Corsair-specific. Full writeup below; recommending we close this as "works as intended by the hardware" with a troubleshooting note, and pull
WasapiRenderKeepAlive.What we ruled out first
Before finding the real cause, did a clean property-level check: dumped every PnP/device property (371 properties total) on the USB dongle, its 3 child interfaces, and both audio endpoints — once with the headset physically off, once with it on and linked. Zero differences across all 371 properties. Combined with
WasapiDeviceWatchercatching nothing during a live drop, this fully confirms Windows has no visibility into the wireless link state at the PnP, USB-device, or WASAPI-endpoint layer. The dongle always reports "present" and "active" to Windows regardless of whether the earcups are actually linked. There's genuinely nothing to hook from inside this process.The actual mechanism
This is a documented, cross-brand wireless-headset firmware feature — an auto-power-off timer built into the headset itself, independent of the OS entirely:
turnOffIntervalconfig value (default 900s = 15 min); users set it to 0 to disable.Sapd/HeadsetControl(open-source cross-platform headset control tool): Corsair Void has a documented-i <minutes>HID command specifically for "inactive time," 0-90 minutes, 0 disables it entirely. This is sent directly to the headset over USB HID — nothing to do with WASAPI, audio streams, or anything visible to the OS audio stack.That last point closes the loop: the timeout is real, per-model, configurable, but only via a direct HID command to the headset — which is exactly why nothing we tried from the audio-stream side (loopback capture, silent render stream) reliably worked. The apparent successes were noise/confounds, as the follow-up testing showed once I controlled for other apps running.
Recommendation
WasapiRenderKeepAlive— it doesn't do what it was meant to do, and shipping it would be false confidence for users hitting this.WasapiDeviceWatcher— it's diagnostic-only, correctly built, and was the tool that helped rule this in.Want me to update PR #726 to drop the keep-alive and keep just the watcher, and draft the troubleshooting note for the issue/docs?
abduznik commentedon Sep 21, 2026
Validated fix, with an important honesty caveat up front.
The honest framing: this is a workaround, not a root-cause fix
We don't control, and can't see, the headset's own firmware idle timer (confirmed via the 371-property diff earlier — completely invisible to the OS at every layer). What this does is give that timer real signal to detect as "activity," so it never fires in the first place. The underlying mechanism — a per-vendor, per-model firmware timer — is untouched and still exists; we're just feeding it what it's already designed to look for.
We deliberately did NOT go the route of reverse-engineering each headset's USB HID protocol (confirmed working for Corsair Void via
Sapd/HeadsetControl'ssetInactiveTime(0)) to directly disable the timer, because:The render-tone approach is generic: it works at the WASAPI layer, applies to any headset with this class of firmware behavior (which is most wireless headsets — JBL, Turtle Beach, Logitech, Corsair, etc. all have documented versions of the same feature), and costs nothing extra to maintain per-vendor.
What changed since the last update
WasapiRenderKeepAlivenow writes a real tone instead ofAUDCLNT_BUFFERFLAGS_SILENT, since a controlled test showed silence does not reliably prevent the drop while real signal does — consistent with the loopback-capture-prevents-it-but-mic-only-doesn't finding.Test results, clean conditions (all other apps closed), same repro as before
This is the second full clean pass after the earlier 630-640s failure with silence, so I'm more confident in this one — real signal, not silence, is the mechanism that actually works, matching the loopback-capture comparison from the start.
Remaining before this is review-ready
WasapiDeviceWatcherwere already addressed (nonblocking callbacks via worker thread, CoInitializeEx on the render thread) — still in place.Ready to mark this out of draft when you say so, or happy to keep iterating first if you want the remaining test coverage done.
fix(windows): address review findings on the keep-alive and device wa…
diag(windows): add opt-in audio device-state watcher for getopenscree…
fix(windows): keep the render endpoint alive during recording
fix(windows): address review findings on the keep-alive and device wa…
diag(windows): add opt-in audio device-state watcher for #724
fix(windows): keep the render endpoint alive during recording
fix(windows): address review findings on the keep-alive and device wa…
github-actions commentedon Sep 25, 2026
Fixed by #726 and merged into
main.This change is assigned to the
Next Releaserelease milestone and is not necessarily available in the latest downloadable desktop release yet. It is currently marked asstatus: pending releaseuntil a packaged release containing it is published.EtienneLescot commentedon Sep 30, 2026
Moved into
v2.0.0as part of the pre-release cut.