feat: Audio tab in the Insert menu with Volume and Mute in background #1270

Merged
bvtterbroot merged 1 commit from feat/audio-settings into main 2026-11-10 18:25:01 +00:00
bvtterbroot commented 2026-11-09 19:57:50 +00:00 (Migrated from github.com)

What

The Insert menu gets a fourth tab, Audio, between Graphics and Gameplay, with two rows:

  • Volume, 0 to 100% in steps of 5 (default 100%). The mixer (host/nn_audio.c) scales each frame before the output, on every output channel (stereo or #1182's 5.1). A change ramps over one 5 ms frame, so it does not click. Gains stay at or below 1, so nothing clips. At 100% the frame is left alone.
  • Mute in background (default Off): silent while another window has the focus. An overlay in front (a tool or no-activate window, as host/nn_hid.c's game_in_front treats Steam's or Discord's) still counts as the game's window.

The host reads both through the renderer's option table every 100 ms, as new ids HOS_OPT_VOLUME = 27 and HOS_OPT_MUTE_BACKGROUND = 28 (gpu/vk/vk_api.h; 18 to 22 are the motion rows, 24 to 26 DLSS). Without the Vulkan renderer's menu the game plays at full volume. Both are saved in hookshot_options.ini (volume=, mute_in_background=).

menu_hit::tab_slot_w now takes the tab count instead of assuming 3, so the tab strip and its clicks fit four tabs.

Why

There was no way to set the game's volume apart from the Windows mixer, and no way to silence it while alt-tabbed. Both are common requests for a PC game.

#1240's Audio output row (Auto, Stereo, 5.1) would fit in this tab too. Whichever of the two lands second can move it here.

How it was tested

  • Machine: Windows 11, AMD Radeon RX 7900 XTX (driver 32.0.32015.7029), Ryzen 7 7800X3D.
  • In game: Kokiri Forest and Link's house. Volume from 100% to 0% and back: the level follows with no clicks, 0% is silent. Mute in background On: alt-tab to another window silences the game within about 0.1 s, switching back brings the sound back. Frame rate unchanged (the change is in the audio thread only).
  • Game-free: new tests/test_audio_volume.py compiles audio_apply_gain from the source and checks the ramp, the end gain, no clipping and equal gain on 2 and 6 channels. It also checks the option ids against the renderer's table and the menu's rows and defaults. tests/test_menu_hit.cpp gains checks for a four-tab strip. tests/run_all.py shows no new failures on Windows: the 3 that fail locally fail the same way on main.
  • readme: the options table and a note under it.

🤖 Generated with Claude Code

## What The Insert menu gets a fourth tab, **Audio**, between Graphics and Gameplay, with two rows: - **Volume**, 0 to 100% in steps of 5 (default 100%). The mixer (`host/nn_audio.c`) scales each frame before the output, on every output channel (stereo or #1182's 5.1). A change ramps over one 5 ms frame, so it does not click. Gains stay at or below 1, so nothing clips. At 100% the frame is left alone. - **Mute in background** (default Off): silent while another window has the focus. An overlay in front (a tool or no-activate window, as `host/nn_hid.c`'s `game_in_front` treats Steam's or Discord's) still counts as the game's window. The host reads both through the renderer's option table every 100 ms, as new ids `HOS_OPT_VOLUME = 27` and `HOS_OPT_MUTE_BACKGROUND = 28` (`gpu/vk/vk_api.h`; 18 to 22 are the motion rows, 24 to 26 DLSS). Without the Vulkan renderer's menu the game plays at full volume. Both are saved in `hookshot_options.ini` (`volume=`, `mute_in_background=`). `menu_hit::tab_slot_w` now takes the tab count instead of assuming 3, so the tab strip and its clicks fit four tabs. ## Why There was no way to set the game's volume apart from the Windows mixer, and no way to silence it while alt-tabbed. Both are common requests for a PC game. #1240's **Audio output** row (Auto, Stereo, 5.1) would fit in this tab too. Whichever of the two lands second can move it here. ## How it was tested - **Machine:** Windows 11, AMD Radeon RX 7900 XTX (driver 32.0.32015.7029), Ryzen 7 7800X3D. - **In game:** Kokiri Forest and Link's house. Volume from 100% to 0% and back: the level follows with no clicks, 0% is silent. Mute in background On: alt-tab to another window silences the game within about 0.1 s, switching back brings the sound back. Frame rate unchanged (the change is in the audio thread only). - **Game-free:** new `tests/test_audio_volume.py` compiles `audio_apply_gain` from the source and checks the ramp, the end gain, no clipping and equal gain on 2 and 6 channels. It also checks the option ids against the renderer's table and the menu's rows and defaults. `tests/test_menu_hit.cpp` gains checks for a four-tab strip. `tests/run_all.py` shows no new failures on Windows: the 3 that fail locally fail the same way on `main`. - **readme:** the options table and a note under it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-09 20:05:45 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice, tidy change. Option ids 27/28 are free on main and in the open PRs, no API version bump is needed (no struct change), and the 4-tab hit-testing, Q/E cycling (all through TAB_COUNT) and the rows' flags (slider, stops_at_ends, preset_exempt) are consistent. Ini keys come out as volume and mute_in_background, and an old ini without them keeps the 100 / Off defaults. The gain ramp is correct (0..1 so nothing clips, same gain on every channel, applied after the mix in stereo and 5.1), and the tests are registered with their compiler.

One real thing, plus nits:

  • The foreground check treats any tool or no-activate window as the game's, with no memory. game_in_front in nn_hid.c only does that while the last normal foreground window was ours. Here, with the game in the background, a notification toast or a tray flyout takes the foreground, the check says "front", and the sound comes back until it goes away. Worth sharing that logic with game_in_front (or copying its last_normal_was_us state).
  • Heads-up on #1240 (Audio output Auto/Stereo/5.1 in Gameplay): it touches the same files (vk_menu.cpp, nn_audio.c, readme, run_all.py) and that row belongs in this new Audio tab, so whichever lands second needs a rebase, and the row should probably move here.
  • The volume is linear, so 50% is -6 dB and the bottom half of the slider is nearly all very quiet. A squared curve (gain = v*v) usually feels more even. Fine to leave if you prefer.
  • Stale comments (inline).
  • Not a blocker: the tab slots are now (w - 170) / 4, so please eyeball that GAMEPLAY / CONTROLS still fit at the bold 13 px size at scale 1 and in a narrow window.
Nice, tidy change. Option ids 27/28 are free on main and in the open PRs, no API version bump is needed (no struct change), and the 4-tab hit-testing, Q/E cycling (all through TAB_COUNT) and the rows' flags (slider, stops_at_ends, preset_exempt) are consistent. Ini keys come out as `volume` and `mute_in_background`, and an old ini without them keeps the 100 / Off defaults. The gain ramp is correct (0..1 so nothing clips, same gain on every channel, applied after the mix in stereo and 5.1), and the tests are registered with their compiler. One real thing, plus nits: - The foreground check treats any tool or no-activate window as the game's, with no memory. `game_in_front` in nn_hid.c only does that while the last normal foreground window was ours. Here, with the game in the background, a notification toast or a tray flyout takes the foreground, the check says "front", and the sound comes back until it goes away. Worth sharing that logic with `game_in_front` (or copying its `last_normal_was_us` state). - Heads-up on #1240 (Audio output Auto/Stereo/5.1 in Gameplay): it touches the same files (vk_menu.cpp, nn_audio.c, readme, run_all.py) and that row belongs in this new Audio tab, so whichever lands second needs a rebase, and the row should probably move here. - The volume is linear, so 50% is -6 dB and the bottom half of the slider is nearly all very quiet. A squared curve (gain = v*v) usually feels more even. Fine to leave if you prefer. - Stale comments (inline). - Not a blocker: the tab slots are now (w - 170) / 4, so please eyeball that GAMEPLAY / CONTROLS still fit at the bold 13 px size at scale 1 and in a narrow window.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:05:46 +00:00

Nit: the ids are 27 and 28 (HOS_OPT_VOLUME / HOS_OPT_MUTE_BACKGROUND), not 18 and 19 (those are the gyro's). Same on the next line.

Nit: the ids are 27 and 28 (HOS_OPT_VOLUME / HOS_OPT_MUTE_BACKGROUND), not 18 and 19 (those are the gyro's). Same on the next line.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:05:45 +00:00

Any TOOLWINDOW / NOACTIVATE foreground counts as ours, whatever was in front before. nn_hid.c's game_in_front only allows that while the last normal foreground window was the game's (last_normal_was_us), so with the game in the background a toast or tray flyout would unmute it. Same state would fix it here.

Any TOOLWINDOW / NOACTIVATE foreground counts as ours, whatever was in front before. nn_hid.c's game_in_front only allows that while the last normal foreground window was the game's (last_normal_was_us), so with the game in the background a toast or tray flyout would unmute it. Same state would fix it here.
bvtterbroot commented 2026-11-09 20:46:21 +00:00 (Migrated from github.com)

Thanks for the careful review. All points are in 5976ed6 (the branch is also rebased on current main):

  • Overlays: the foreground check now keeps its own last_normal_was_us, as game_in_front does: a tool or no-activate window counts as the game's only while the last normal window in front was the game's. The mixer thread keeps its own copy of the state rather than sharing game_in_front's statics across threads. The decision is a small pure function (audio_front_decide), and tests/test_audio_volume.py checks the toast-behind-the-game case.
  • Volume curve: now squared (50% is -12 dB). The readme says so.
  • Stale comments: fixed (27, 28).
  • Tab widths: measured with GDI (Segoe UI, 13 px bold at scale 1): GAMEPLAY is 69 px and CONTROLS 68 px. In 77 px slots, GAMEPLAY's highlight came within 1.5 px of CONTROLS' title. kTabSide is now 70 (the [Q / LB] / [E / RB] hints are 49 / 47 px from 16 in), so the slots are 85 px with ~14 px between the highlight and the next title. test_menu_hit.cpp has the new edges. Checked in game at scale 2 (1440p).
  • #1240: agreed, its Audio output row belongs in this tab. Happy to move it if #1240 lands first, or @janvitos can move it if this one does.
Thanks for the careful review. All points are in 5976ed6 (the branch is also rebased on current main): - **Overlays:** the foreground check now keeps its own `last_normal_was_us`, as `game_in_front` does: a tool or no-activate window counts as the game's only while the last normal window in front was the game's. The mixer thread keeps its own copy of the state rather than sharing `game_in_front`'s statics across threads. The decision is a small pure function (`audio_front_decide`), and `tests/test_audio_volume.py` checks the toast-behind-the-game case. - **Volume curve:** now squared (50% is -12 dB). The readme says so. - **Stale comments:** fixed (27, 28). - **Tab widths:** measured with GDI (Segoe UI, 13 px bold at scale 1): GAMEPLAY is 69 px and CONTROLS 68 px. In 77 px slots, GAMEPLAY's highlight came within 1.5 px of CONTROLS' title. `kTabSide` is now 70 (the `[Q / LB]` / `[E / RB]` hints are 49 / 47 px from 16 in), so the slots are 85 px with ~14 px between the highlight and the next title. `test_menu_hit.cpp` has the new edges. Checked in game at scale 2 (1440p). - **#1240:** agreed, its Audio output row belongs in this tab. Happy to move it if #1240 lands first, or @janvitos can move it if this one does.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:43:12 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the review points are all addressed:

  • Overlays: audio_window_in_front now keeps its own last_normal_was_us, same logic as game_in_front, so a toast or tray flyout behind the game stays muted. The decision is a separate function and the test covers it.
  • The 18/19 comments now say 27/28.
  • Volume is on a squared curve, and the readme says so.
  • Tab width: kTabSide is 70, and tab_slot_w takes the tab count.

One blocker left: the PR no longer merges. GitHub reports it as dirty, since #1240 (Audio output) has merged into main after your rebase base (7dbdcdc). It touches the same hunks in vk_menu.cpp, vk_menu.h, nn_audio.c, readme.md and tests/run_all.py. Please rebase again.

While you are in there, #1240 put Audio output in the Gameplay tab. It would fit better in your new Audio tab, but that is optional. If you move it, tests/test_audio_output_menu.py asserts the row's tab, so update that too. The ids 27/28 do not collide with anything on main.

No checks are reported on the branch, so I could not see CI. I did not build or run it. Once it merges cleanly this looks good to me.

Thanks, the review points are all addressed: - Overlays: audio_window_in_front now keeps its own last_normal_was_us, same logic as game_in_front, so a toast or tray flyout behind the game stays muted. The decision is a separate function and the test covers it. - The 18/19 comments now say 27/28. - Volume is on a squared curve, and the readme says so. - Tab width: kTabSide is 70, and tab_slot_w takes the tab count. One blocker left: the PR no longer merges. GitHub reports it as dirty, since #1240 (Audio output) has merged into main after your rebase base (7dbdcdc). It touches the same hunks in vk_menu.cpp, vk_menu.h, nn_audio.c, readme.md and tests/run_all.py. Please rebase again. While you are in there, #1240 put Audio output in the Gameplay tab. It would fit better in your new Audio tab, but that is optional. If you move it, tests/test_audio_output_menu.py asserts the row's tab, so update that too. The ids 27/28 do not collide with anything on main. No checks are reported on the branch, so I could not see CI. I did not build or run it. Once it merges cleanly this looks good to me.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:43:13 +00:00
hckrman101 (Migrated from github.com) commented 2026-11-09 22:43:13 +00:00

Fixed in 5976ed6: it keeps its own last_normal_was_us and the decision is tested.

Fixed in 5976ed6: it keeps its own last_normal_was_us and the decision is tested.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:43:15 +00:00
hckrman101 (Migrated from github.com) commented 2026-11-09 22:43:15 +00:00

Fixed in 5976ed6, now 27/28.

Fixed in 5976ed6, now 27/28.
hckrman101 commented 2026-11-10 05:26:22 +00:00 (Migrated from github.com)

#1459 rewrites the entire menu, I can assist rebasing this PR on top of the other (trying to merge ASAP)

https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/1459 rewrites the entire menu, I can assist rebasing this PR on top of the other (trying to merge ASAP)
hckrman101 commented 2026-11-10 17:49:05 +00:00 (Migrated from github.com)

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!

Heads-up: this conflicts with main now (a lot landed today). Could you merge main into the branch (or rebase) and resolve the conflicts? Thanks!
bvtterbroot commented 2026-11-10 18:04:23 +00:00 (Migrated from github.com)

Rebased onto the ImGui menu (#1459) and #1240. Thanks for the offer. Since #1459 replaced the menu, I redid the menu part on top of current main (9edb3a5) as one commit, df2d84c, instead of carrying the old GDI tab-strip changes:

  • Audio is a tab of the new strip, between Graphics and Gameplay (TAB_COUNT 5). The tab widths come from the titles now, so the vk_menu_hit.h and test_menu_hit.cpp changes are gone.
  • Audio output moved from Gameplay into the Audio tab, and tests/test_audio_output_menu.py expects TAB_AUDIO.
  • Mixer (host/nn_audio.c): unchanged from the reviewed version. It has the squared curve and the overlay rule with its own last_normal_was_us, applied on out_channels. The ids are still 27/28, and nothing on main uses them.
  • Tests: test_audio_volume.py declares RUN_ALL_ARGS like the other tests now. run_all.py: 258 passed. The 3 that fail locally on Windows (test_nup_out_of_space, test_fpx_sticky, test_precompile_compute_guard) fail the same way on main.
  • In game: built at 484cfd1 plus this change and played on Windows 11 with an RX 7900 XTX. The Audio tab shows its three rows in the new style, the volume slider and Mute in background work, and Audio output works as before. The rebase onto 9edb3a5 merged cleanly (the only conflict was run_all.py's move to per-test declarations), so I did not rebuild for it.
Rebased onto the ImGui menu (#1459) and #1240. Thanks for the offer. Since #1459 replaced the menu, I redid the menu part on top of current main (`9edb3a5`) as one commit, `df2d84c`, instead of carrying the old GDI tab-strip changes: - **Audio** is a tab of the new strip, between Graphics and Gameplay (`TAB_COUNT` 5). The tab widths come from the titles now, so the `vk_menu_hit.h` and `test_menu_hit.cpp` changes are gone. - **Audio output** moved from Gameplay into the Audio tab, and `tests/test_audio_output_menu.py` expects `TAB_AUDIO`. - **Mixer** (`host/nn_audio.c`): unchanged from the reviewed version. It has the squared curve and the overlay rule with its own `last_normal_was_us`, applied on `out_channels`. The ids are still 27/28, and nothing on main uses them. - **Tests:** `test_audio_volume.py` declares `RUN_ALL_ARGS` like the other tests now. `run_all.py`: 258 passed. The 3 that fail locally on Windows (`test_nup_out_of_space`, `test_fpx_sticky`, `test_precompile_compute_guard`) fail the same way on main. - **In game:** built at `484cfd1` plus this change and played on Windows 11 with an RX 7900 XTX. The Audio tab shows its three rows in the new style, the volume slider and Mute in background work, and Audio output works as before. The rebase onto `9edb3a5` merged cleanly (the only conflict was `run_all.py`'s move to per-test declarations), so I did not rebuild for it.
hckrman101 (Migrated from github.com) approved these changes 2026-11-10 18:13:13 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for redoing this on top of the ImGui menu. It looks good to me.

  • The mixer part matches what I reviewed at 5976ed6: the squared curve, the overlay rule with its own last_normal_was_us, and the ramp on out_channels. On Linux the foreground calls go through the posix windows.h shim, the same way nn_hid.c's do.
  • Audio is now tab 1 and TAB_COUNT is 5. Everything that uses tab numbers goes through the enum: Q/E cycling, tab_selected, TAB_MOTION/TAB_PAGE, and the tests that parse enum MenuTab. Audio output keeps its ini key because the row name did not change, and test_audio_output_menu.py now expects AUDIO.
  • Ids 27/28 are still free on main. The new test's duplicate-id check also covers the HDR/Discord enum. test_audio_volume.py declares RUN_ALL_ARGS, and run_all.py is untouched. That matches #1822.

Heads-up, not a blocker: #1737 (Volume slider, open) adds the same opts.volume / HOS_OPT_VOLUME / is_slider changes and its own gain in nn_audio.c, with id 44. #1560 also uses 44. Whichever of #1270 and #1737 lands second has to drop its own copy instead of resolving the conflict line by line. Otherwise you'd end up with two volume rows or two gains applied.

No CI checks are reported on the branch, and GitHub shows it as mergeable with main. I didn't build or run it.

Thanks for redoing this on top of the ImGui menu. It looks good to me. - The mixer part matches what I reviewed at 5976ed6: the squared curve, the overlay rule with its own `last_normal_was_us`, and the ramp on `out_channels`. On Linux the foreground calls go through the posix windows.h shim, the same way nn_hid.c's do. - Audio is now tab 1 and `TAB_COUNT` is 5. Everything that uses tab numbers goes through the enum: Q/E cycling, `tab_selected`, `TAB_MOTION`/`TAB_PAGE`, and the tests that parse `enum MenuTab`. Audio output keeps its ini key because the row name did not change, and `test_audio_output_menu.py` now expects `AUDIO`. - Ids 27/28 are still free on main. The new test's duplicate-id check also covers the HDR/Discord enum. `test_audio_volume.py` declares `RUN_ALL_ARGS`, and run_all.py is untouched. That matches #1822. Heads-up, not a blocker: #1737 (Volume slider, open) adds the same `opts.volume` / `HOS_OPT_VOLUME` / `is_slider` changes and its own gain in nn_audio.c, with id 44. #1560 also uses 44. Whichever of #1270 and #1737 lands second has to drop its own copy instead of resolving the conflict line by line. Otherwise you'd end up with two volume rows or two gains applied. No CI checks are reported on the branch, and GitHub shows it as mergeable with main. I didn't build or run it.
Commenting is not possible because the repository is archived.
No description provided.