fix(audio): the output comes back after its device goes away, and follows Windows' default #2190

Merged
rakefire-impala merged 1 commit from fix/audio-device-loss into main 2026-11-11 16:38:36 +00:00
rakefire-impala commented 2026-11-11 06:55:07 +00:00 (Migrated from github.com)

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, then 5 on 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):

audio: waveOutWrite failed (MMRESULT 6)
audio: waveOutUnprepareHeader failed (MMRESULT 5)
audio: waveOutUnprepareHeader failed (MMRESULT 5)
...

6 is MMSYSERR_NODRIVER and 5 is MMSYSERR_INVALHANDLE: the device and then the handle are gone. In output_frame, a failed waveOutUnprepareHeader returns 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:

  • A device gone reopens the default. A waveOut call failing with MMSYSERR_BADDEVICEID, MMSYSERR_INVALHANDLE or MMSYSERR_NODRIVER closes the output and opens the default again (HOS_AUDIO_DEVICE first when it's set, as at start-up).
  • Retried until something opens. When no device opens, for example while the audio service is down, the default is tried again every second.
  • Follows Windows' default. While the output is on the default device, au_default_key asks 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.
  • The stall rule's limit resets. A device that takes nothing for 1.5 s still moves to the next candidate up to 6 times, but the count starts again after 30 s of sound.

Linux is unchanged: au_default_key returns 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:

Case Log Sound
Headset off Windows' default output changed: the output opened again on the default device on the speakers within a second of Windows switching its default
Headset back on the same line when Windows switches back back on the headset
Audio service restarted (Stop-Service AudioEndpointBuilder, the Bluetooth adapter restarted, Start-Service AudioSrv) waveOutWrite failed (MMRESULT 6), the output device went away, waveOutOpen failed once a second while the service was down, then output on the default device back 2 s after the write failed, then on the headset 3 s after that, when Windows made it the default again; the next 10 s played 1006 blocks

Before 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.py compiles 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.py on main 4d68bc83 with 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.

**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`, then `5` on 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): ``` audio: waveOutWrite failed (MMRESULT 6) audio: waveOutUnprepareHeader failed (MMRESULT 5) audio: waveOutUnprepareHeader failed (MMRESULT 5) ... ``` `6` is `MMSYSERR_NODRIVER` and `5` is `MMSYSERR_INVALHANDLE`: the device and then the handle are gone. In `output_frame`, a failed `waveOutUnprepareHeader` returns 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`: - **A device gone reopens the default.** A waveOut call failing with `MMSYSERR_BADDEVICEID`, `MMSYSERR_INVALHANDLE` or `MMSYSERR_NODRIVER` closes the output and opens the default again (`HOS_AUDIO_DEVICE` first when it's set, as at start-up). - **Retried until something opens.** When no device opens, for example while the audio service is down, the default is tried again every second. - **Follows Windows' default.** While the output is on the default device, `au_default_key` asks 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. - **The stall rule's limit resets.** A device that takes nothing for 1.5 s still moves to the next candidate up to 6 times, but the count starts again after 30 s of sound. Linux is unchanged: `au_default_key` returns 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: | Case | Log | Sound | |---|---|---| | Headset off | `Windows' default output changed: the output opened again on the default device` | on the speakers within a second of Windows switching its default | | Headset back on | the same line when Windows switches back | back on the headset | | Audio service restarted (`Stop-Service AudioEndpointBuilder`, the Bluetooth adapter restarted, `Start-Service AudioSrv`) | `waveOutWrite failed (MMRESULT 6)`, `the output device went away`, `waveOutOpen failed` once a second while the service was down, then `output on the default device` | back **2 s** after the write failed, then on the headset **3 s** after that, when Windows made it the default again; the next 10 s played 1006 blocks | Before 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.py` compiles 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.py` on main `4d68bc83` with 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.
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 16:29:10 +00:00
hckrman101 (Migrated from github.com) left a comment

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_close ignores errors on a dead handle, so nothing waits on a missing device, and everything runs on the one renderer thread that already owned wout, so there is no new locking. On Linux au_default_key returns 0 and waveOutMessage is 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:

  • If a device opens fine but every waveOutWrite then fails with NODRIVER/INVALHANDLE (a half-dead driver), output_move runs open/close on every frame and logs each time. It has no backoff. Reusing out_retry_at to space those moves at least a second apart would keep it cheap.
  • au_default_key (waveOutGetDevCaps plus waveOutMessage to 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.

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_close` ignores errors on a dead handle, so nothing waits on a missing device, and everything runs on the one renderer thread that already owned `wout`, so there is no new locking. On Linux `au_default_key` returns 0 and `waveOutMessage` is 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: - If a device opens fine but every `waveOutWrite` then fails with `NODRIVER`/`INVALHANDLE` (a half-dead driver), `output_move` runs open/close on every frame and logs each time. It has no backoff. Reusing `out_retry_at` to space those moves at least a second apart would keep it cheap. - `au_default_key` (`waveOutGetDevCaps` plus `waveOutMessage` to 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.
Commenting is not possible because the repository is archived.
No description provided.