Mix for 5.1 when the default output has 5.1 speakers #1182

Merged
janvitos merged 1 commit from fix/surround-5.1 into main 2026-11-09 18:22:23 +00:00
janvitos commented 2026-11-09 16:33:16 +00:00 (Migrated from github.com)

Fixes #1124.

What

Hookshot now gives the game the default output's real channel count, so it mixes 5.1 when the output has 5.1.

  • Windows: reads the default endpoint's shared-mode mix format through IMMDevice and IAudioClient.
  • Linux: reads SDL3's SDL_GetAudioDeviceFormat, or SDL2's SDL_GetDefaultAudioInfo. That call is looked up at runtime, so an older SDL still plays, in stereo.
  • 6 channels or more: the game is told 5.1. The device sink's six channels go out unchanged in the order FL FR FC LFE BL BR, which is the same on Windows and SDL. On Windows the device opens as WAVE_FORMAT_EXTENSIBLE with the speaker mask.
  • Fewer channels, or a 5.1 open that fails: stereo with the existing downmix, as before.
  • Limiter: one gain for every channel, so the sound's position doesn't shift.
  • Override: HOS_AUDIO_CHANNELS=2 or 6, documented with the other HOS_AUDIO_* switches at the top of nn_audio.c.
  • SDL3 frames: the pull now hands SDL3's stream only whole sample frames. 4096 bytes isn't a whole number of 12-byte 5.1 frames, so every piece was refused and 5.1 played silence.

Why

The game asks once for the output's channel count (GetActiveAudioDeviceChannelCountForOutput) and pans for that many speakers. Hookshot always answered 2, so nothing was ever placed behind the player. On a 5.1 system the whole game came from the front speakers; in the issue, Epona's hooves in the intro are "but a whisper" instead of behind you.

How it was tested

  • Machine: Linux (CachyOS), RTX 4070 SUPER, NVIDIA 620.70.06, with a temporary PipeWire 5.1 virtual sink set as the default output.
  • Build: a full game build with this change on 8b2e1d3.
  • 5.1 output: the log says audio: 5.1 for the game (the default output has 6 channels). In gameplay all six channels carry sound, centre, LFE and rears included.
  • Stereo output: the game is told stereo and plays as before.
  • SDL paths: both the SDL3 and SDL2 paths were checked.
  • Windows: the device query and the extensible 5.1 open were run under Wine 11 only. A check on real Windows 5.1 hardware would be welcome.
  • Suite: tests/test_audio_output_errors.py follows the new render_frame signature. On this branch (rebased on 1f6fbdb), python -I -B tests/run_all.py gives 131 passed, 0 failed, 17 skipped on Linux.

During testing I also hit a speaker channel going silent at random, in stereo too. That is a separate bug with a separate fix in #1181. This PR doesn't depend on it.

🤖 Generated with Claude Code

Fixes #1124. ## What Hookshot now gives the game the default output's real channel count, so it mixes 5.1 when the output has 5.1. - **Windows:** reads the default endpoint's shared-mode mix format through IMMDevice and IAudioClient. - **Linux:** reads SDL3's `SDL_GetAudioDeviceFormat`, or SDL2's `SDL_GetDefaultAudioInfo`. That call is looked up at runtime, so an older SDL still plays, in stereo. - **6 channels or more:** the game is told 5.1. The device sink's six channels go out unchanged in the order FL FR FC LFE BL BR, which is the same on Windows and SDL. On Windows the device opens as `WAVE_FORMAT_EXTENSIBLE` with the speaker mask. - **Fewer channels, or a 5.1 open that fails:** stereo with the existing downmix, as before. - **Limiter:** one gain for every channel, so the sound's position doesn't shift. - **Override:** `HOS_AUDIO_CHANNELS=2` or `6`, documented with the other `HOS_AUDIO_*` switches at the top of `nn_audio.c`. - **SDL3 frames:** the pull now hands SDL3's stream only whole sample frames. 4096 bytes isn't a whole number of 12-byte 5.1 frames, so every piece was refused and 5.1 played silence. ## Why The game asks once for the output's channel count (`GetActiveAudioDeviceChannelCountForOutput`) and pans for that many speakers. Hookshot always answered 2, so nothing was ever placed behind the player. On a 5.1 system the whole game came from the front speakers; in the issue, Epona's hooves in the intro are "but a whisper" instead of behind you. ## How it was tested - **Machine:** Linux (CachyOS), RTX 4070 SUPER, NVIDIA 620.70.06, with a temporary PipeWire 5.1 virtual sink set as the default output. - **Build:** a full game build with this change on 8b2e1d3. - **5.1 output:** the log says `audio: 5.1 for the game (the default output has 6 channels)`. In gameplay all six channels carry sound, centre, LFE and rears included. - **Stereo output:** the game is told stereo and plays as before. - **SDL paths:** both the SDL3 and SDL2 paths were checked. - **Windows:** the device query and the extensible 5.1 open were run under Wine 11 only. A check on real Windows 5.1 hardware would be welcome. - **Suite:** `tests/test_audio_output_errors.py` follows the new `render_frame` signature. On this branch (rebased on 1f6fbdb), `python -I -B tests/run_all.py` gives 131 passed, 0 failed, 17 skipped on Linux. During testing I also hit a speaker channel going silent at random, in stereo too. That is a separate bug with a separate fix in #1181. This PR doesn't depend on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) approved these changes 2026-11-09 17:59:54 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice one, this looks right to me. What I checked:

  • GetActiveAudioDeviceChannelCountForOutput now returns au_speakers(), and the sink order FL FR FC LFE RL RR goes straight through to the device. That's the same order as Windows' 0x3F mask and SDL's 5.1 layout, and the stereo downmix coefficients are unchanged.
  • The buffer sizes are consistent. blocks/pcm are sized for 6 channels, dwBufferLength is two frames at the opened channel count, and the memcpy offsets, the dump header (align/brate) and the per-frame dump writes all follow out_channels. render_frame is called with out_channels, so a stereo fallback after the game was told 6 just downmixes.
  • The SDL3 pull3 fix is real. SDL_PutAudioStreamData rejects partial sample frames, 4096 % 12 = 4, and want always comes in whole frames, so the loop still ends at 0.
  • The SDL struct layouts are right: SDL3 SDL_AudioSpec {format, channels, freq} with SDL_GetAudioDeviceFormat(0xFFFFFFFF, ...), and SDL2's SDL_AudioSpec with SDL_GetDefaultAudioInfo(&name, &spec, 0) plus SDL_free on the name. Looking these up lazily via GetProcAddress keeps an older SDL2 working.
  • On Windows, -lole32 is already in HOST_LIBS. The GUIDs are local, so there's no uuid lib dependency, and CoInitializeEx is balanced (an RPC_E_CHANGED_MODE thread still works with the existing apartment).
  • The fallback loop rotates through every candidate at 5.1 and then again at stereo in the same order. The new surround/fallback test cases exercise the real output section (it's extracted from #define NBLOCKS, so OUT_MAX_CH is included).

Only small things, nothing blocking:

  • au_speakers holds au_lock across the device query (inline comment).
  • If no device opens at all, the 5.1 attempt now adds a second round of waveOutOpen errors before the stereo round. That's harmless, just noisier logs.
  • This touches the same file as #1181 and #1063, so whichever lands second will need a small rebase around output_frame/render_frame.
Nice one, this looks right to me. What I checked: - `GetActiveAudioDeviceChannelCountForOutput` now returns `au_speakers()`, and the sink order FL FR FC LFE RL RR goes straight through to the device. That's the same order as Windows' 0x3F mask and SDL's 5.1 layout, and the stereo downmix coefficients are unchanged. - The buffer sizes are consistent. `blocks`/`pcm` are sized for 6 channels, `dwBufferLength` is two frames at the opened channel count, and the `memcpy` offsets, the dump header (`align`/`brate`) and the per-frame dump writes all follow `out_channels`. `render_frame` is called with `out_channels`, so a stereo fallback after the game was told 6 just downmixes. - The SDL3 `pull3` fix is real. `SDL_PutAudioStreamData` rejects partial sample frames, 4096 % 12 = 4, and `want` always comes in whole frames, so the loop still ends at 0. - The SDL struct layouts are right: SDL3 `SDL_AudioSpec {format, channels, freq}` with `SDL_GetAudioDeviceFormat(0xFFFFFFFF, ...)`, and SDL2's `SDL_AudioSpec` with `SDL_GetDefaultAudioInfo(&name, &spec, 0)` plus `SDL_free` on the name. Looking these up lazily via `GetProcAddress` keeps an older SDL2 working. - On Windows, `-lole32` is already in `HOST_LIBS`. The GUIDs are local, so there's no uuid lib dependency, and `CoInitializeEx` is balanced (an RPC_E_CHANGED_MODE thread still works with the existing apartment). - The fallback loop rotates through every candidate at 5.1 and then again at stereo in the same order. The new `surround`/`fallback` test cases exercise the real output section (it's extracted from `#define NBLOCKS`, so `OUT_MAX_CH` is included). Only small things, nothing blocking: - `au_speakers` holds `au_lock` across the device query (inline comment). - If no device opens at all, the 5.1 attempt now adds a second round of `waveOutOpen` errors before the stereo round. That's harmless, just noisier logs. - This touches the same file as #1181 and #1063, so whichever lands second will need a small rebase around `output_frame`/`render_frame`.
@ -657,1 +760,4 @@
if (wout || channels == 2 || dump) break;
hos_log("audio: no device took 5.1: stereo instead");
channels = 2;
}
hckrman101 (Migrated from github.com) commented 2026-11-09 17:59:54 +00:00

Nit: this holds the mixer lock across the COM/SDL device query. On Windows, Activate/GetMixFormat can take a noticeable moment, for example when an HDMI or Bluetooth endpoint is waking up. Every other audio HLE that takes au_lock waits on it meanwhile. It only happens once at startup, so it's fine as is. If you want to avoid it, you could do the query outside the lock and just publish known (both threads would compute the same value anyway).

Nit: this holds the mixer lock across the COM/SDL device query. On Windows, `Activate`/`GetMixFormat` can take a noticeable moment, for example when an HDMI or Bluetooth endpoint is waking up. Every other audio HLE that takes `au_lock` waits on it meanwhile. It only happens once at startup, so it's fine as is. If you want to avoid it, you could do the query outside the lock and just publish `known` (both threads would compute the same value anyway).
Uncloak2 commented 2026-11-09 18:12:58 +00:00 (Migrated from github.com)

Please add menu options so we can pick 2.0 or 5.1 output.

Please add menu options so we can pick 2.0 or 5.1 output.
hckrman101 commented 2026-11-09 18:22:01 +00:00 (Migrated from github.com)

@Uncloak2 for now this PR picks it automatically: 5.1 when your default output device has 5.1 (or more) speakers, stereo otherwise. If you want to force one, set HOS_AUDIO_CHANNELS=2 (stereo) or HOS_AUDIO_CHANNELS=6 (5.1), e.g. as a line in hookshot_env.txt next to the game.

An in-game option (Auto / Stereo / 5.1 in the Insert menu) would be a nice follow-up. The game only reads the channel count when it starts, so it'd need a restart like Output resolution, but it can be saved in hookshot_options.ini the same way. I'd keep it out of this PR so this one stays small.

@Uncloak2 for now this PR picks it automatically: 5.1 when your default output device has 5.1 (or more) speakers, stereo otherwise. If you want to force one, set `HOS_AUDIO_CHANNELS=2` (stereo) or `HOS_AUDIO_CHANNELS=6` (5.1), e.g. as a line in `hookshot_env.txt` next to the game. An in-game option (Auto / Stereo / 5.1 in the Insert menu) would be a nice follow-up. The game only reads the channel count when it starts, so it'd need a restart like Output resolution, but it can be saved in `hookshot_options.ini` the same way. I'd keep it out of this PR so this one stays small.
Commenting is not possible because the repository is archived.
No description provided.