fix(audio): the output comes back after its device goes away, and follows Windows' default #2190
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2190
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/audio-device-loss"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
In short: a Bluetooth headset going away, or the Windows audio service restarting, left the game silent until it was restarted. The failed waveOut call (
MMRESULT 6, then5on every frame) was only logged and never led to a device being opened again. Now the output reopens on Windows' default device, retries every second until one opens, and follows Windows when its default changes.Problem
When the audio output device goes away, the game stays silent for the rest of the run, on every device, while other programs play. A Bluetooth headset that disconnects does it, and so does restarting the Windows audio service, which some Bluetooth drivers need to bring a headset back.
The log from one such run (RX 5500M laptop, Sony WH-CH720N headset):
6isMMSYSERR_NODRIVERand5isMMSYSERR_INVALHANDLE: the device and then the handle are gone. Inoutput_frame, a failedwaveOutUnprepareHeaderreturns before the 1.5 s stall rule, so the same block was tried again on every frame and no device was ever opened again. The stall rule itself, where it did run, moved to the next device in the list rather than to Windows' default, never moved back when the headset returned, and stopped after 6 tries per run.Change
All in the output section of
host/nn_audio.c:MMSYSERR_BADDEVICEID,MMSYSERR_INVALHANDLEorMMSYSERR_NODRIVERcloses the output and opens the default again (HOS_AUDIO_DEVICEfirst when it's set, as at start-up).au_default_keyasks Windows once a second which device that is: the waveOut mapper's preferred device (DRVM_MAPPER_PREFERRED_GET), its number and name. When that changes, the output moves there, so it goes back to the headset when Windows does. A device opened as the default otherwise stays on that device after Windows switches.Linux is unchanged:
au_default_keyreturns 0 there, and SDL follows the default device itself.Tested
RX 5500M laptop, Windows 11, Sony WH-CH720N over Bluetooth, the game playing sound throughout. Times from a clock-to-log recorder:
Windows' default output changed: the output opened again on the default deviceStop-Service AudioEndpointBuilder, the Bluetooth adapter restarted,Start-Service AudioSrv)waveOutWrite failed (MMRESULT 6),the output device went away,waveOutOpen failedonce a second while the service was down, thenoutput on the default deviceBefore this change, the headset going away and the service restart each left the game silent until it was restarted, and it never moved back to the headset.
Tests
tests/test_audio_output_errors.pycompiles the real output section against fake waveOut calls. Four new cases:lost: a write fails with "no driver".gone: every unprepare fails with "invalid handle", as in the log above.service: no device opens for a while, then the default does, retried once a second.default: Windows' default device changes, and the output moves there once and only once.All four fail on current main, where no device is opened again, and pass with this change. The ten existing cases still pass.
tests/run_all.pyon main4d68bc83with this branch merged: 310 passed. The 6 that fail are the same 6 that fail on plain main on this PC; all seven audio checks pass.Looks good. Read through the output section end to end: the device-gone path (write or unprepare failing with 2/5/6) now closes and reopens on the default, no-device retries once a second from
output_frame(no loop, no waiting), and the 1.5 s stall rule is unchanged apart from the count resetting after 30 s of sound.output_closeignores errors on a dead handle, so nothing waits on a missing device, and everything runs on the one renderer thread that already ownedwout, so there is no new locking. On Linuxau_default_keyreturns 0 andwaveOutMessageis under_WIN32, so the follow-the-default part is off there. CI is green, and the real-device table in the description covers the headset and the audio service restart.Two non-blocking nits, both about a device that opens but then fails:
waveOutWritethen fails withNODRIVER/INVALHANDLE(a half-dead driver),output_moveruns open/close on every frame and logs each time. It has no backoff. Reusingout_retry_atto space those moves at least a second apart would keep it cheap.au_default_key(waveOutGetDevCapspluswaveOutMessageto the mapper) runs on the audio thread once a second. While the audio service is restarting, those calls can block for a moment; that would be a short hitch in audio pacing, not a hang, and probably fine in practice.I haven't run it; this review is from reading the diff.
github-sync referenced this pull request2026-11-12 06:22:54 +00:00