From fe08914cb63538ee446755cea6b28fd87f1fa8e8 Mon Sep 17 00:00:00 2001 From: bvtterbroot <157503259+bvtterbroot@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:10:17 +0100 Subject: [PATCH 1/2] feat: Audio tab in the Insert menu with Volume and Mute in background The Insert menu gets a fourth tab, Audio, between Graphics and Gameplay: - Volume, 0 to 100% in steps of 5: the mixer scales each frame before the output, ramping a change over one frame so it does not click. - Mute in background: silent while another window has the focus. An overlay (a tool or no-activate window, as host/nn_hid.c treats Steam's or Discord's) still counts as the game's window. The host reads both through the renderer's option table (18 and 19), every 100 ms. Out of the box nothing changes: 100% and off. Without the Vulkan renderer's menu the game plays at full volume. The tab strip's slot width now follows the tab count (vk_menu_hit.h). Co-Authored-By: Claude Opus 5.5 --- gpu/vk/vk_api.h | 6 +- gpu/vk/vk_menu.cpp | 36 ++++++---- gpu/vk/vk_menu.h | 2 + gpu/vk/vk_menu_hit.h | 4 +- gpu/vk/vk_replay.cpp | 3 +- host/nn_audio.c | 40 +++++++++++ readme.md | 5 ++ tests/run_all.py | 1 + tests/test_audio_volume.py | 139 +++++++++++++++++++++++++++++++++++++ tests/test_menu_hit.cpp | 8 +++ 10 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 tests/test_audio_volume.py diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index ab699f59e..9559f7d44 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -23,6 +23,9 @@ enum { HOS_OPT_GYRO = 18, HOS_OPT_GYRO_SENS_X, HOS_OPT_GYRO_INV_X, HOS_OPT_GYRO_ /* (hookshot) NVIDIA DLSS's camera jitter (host/rx_backend.c, gpu/vk/vk_dlss.cpp): the scene's width and height while DLSS draws, 0 when there is to be no jitter, and the jitter sequence's length. 24 on: 18 to 22 are the motion rows' */ enum { HOS_OPT_DLSS_JITTER_W = 24, HOS_OPT_DLSS_JITTER_H, HOS_OPT_DLSS_JITTER_PHASES }; +/* (hookshot) the Audio tab's rows (host/nn_audio.c reads them every 100 ms): the volume in percent, and no sound while + another window has the focus */ +enum { HOS_OPT_VOLUME = 27, HOS_OPT_MUTE_BACKGROUND }; /* (hookshot) the controls' names (host/nn_hid.c, host/pad_sdl.c, the pad_ and key_ lines of hookshot_options.ini): the Switch's buttons in nn::hid's bit order, and a gamepad's inputs by position, SDL's order (the bottom face @@ -154,7 +157,8 @@ typedef struct MkwVkRenderer { int (*option)(int id); /* (hookshot, the Insert menu) 0: game time follows the clock, 1: the menu is open, 2: dynamic resolution, 3: its fixed scale in percent, 4: the controllers count behind other windows, 5: text speed (0 normal to 3 instant), 6: native mouse look (host/mouse_native.c), 7: the mouse's sensitivity in percent, 8: its up and down swapped (host/nn_hid.c), 18: motion controls from a pad's gyroscope (host/pad_sdl.c), 19 and 22: their horizontal - and vertical sensitivity in percent, 20 and 21: each turned around */ + and vertical sensitivity in percent, 20 and 21: each turned around, + 27: the volume in percent, 28: no sound while another window has the focus (host/nn_audio.c) */ /* a submit with its queue */ void (*nvn_submit_q)(const uint64_t *handles, int n, uint64_t queue); /* (hookshot) a fence of one queue: the value its own last submission signals (nvnQueueFenceSync) */ diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index fa8e940dd..60e9d1009 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -47,9 +47,10 @@ namespace { enum MenuTab { TAB_GRAPHICS = 0, - TAB_GAMEPLAY = 1, - TAB_CONTROLS = 2, - TAB_COUNT = 3 + TAB_AUDIO = 1, + TAB_GAMEPLAY = 2, + TAB_CONTROLS = 3, + TAB_COUNT = 4 }; struct Row { @@ -477,6 +478,14 @@ Row rows[] = { "Bounds-checked buffer reads: a read past a buffer's end is dropped instead of stopping the GPU. Applies after a " "restart.", hn_robust, true}, + // Audio (2 rows) + {TAB_AUDIO, "Volume", &opts.volume, 0, 100, 5, tx_percent, + "How loud the game plays, in percent of its own volume. The game's own sound settings and Windows' volume still apply.", + nullptr, false, true}, + {TAB_AUDIO, "Mute in background", &opts.mute_background, 0, 1, 1, tx_onoff, + "Silences the game while another window has the focus. The sound comes back when you switch to the game.", nullptr, + false, true}, + // Gameplay (3 rows) {TAB_GAMEPLAY, "FPS counter", &opts.show_fps, 0, 1, 1, tx_onoff, "Frames per second in the top right corner.", hn_fps, false, true}, @@ -612,7 +621,7 @@ constexpr int kRows = sizeof rows / sizeof rows[0]; constexpr int kMenuWidth = menu_hit::kWidth; // (at a scale of 1: 720 lines) int cur_tab = TAB_GRAPHICS; -int tab_selected[TAB_COUNT] = {0, 0, 0}; +int tab_selected[TAB_COUNT] = {}; // ---- the rows that take a restart: their values at the start (menu_init, the GPU's at menu_set_gpu_count) ---- namespace mr = menu_restart; @@ -705,6 +714,7 @@ constexpr int64_t kMarqueeStep = 150000000LL, kMarqueeRest = 1200000000LL; // // (hookshot) the mouse (menu_tick): the tab strip's part under the cursor (a tab 0 to 2, 3 the previous, 4 the next; -1 // none); paint_menu's record of each shown row's value's left edge (as with its arrows, whether shown or not) and of the // rows a slider's track was drawn on; the slider held with the left button (its row in the tab, -1 none) +constexpr int kHoverPrev = TAB_COUNT, kHoverNext = TAB_COUNT + 1; // (hover_tab: the strip's ends) int hover_tab = -1; int row_value_l[kRows]; bool row_slider[kRows]; @@ -713,9 +723,9 @@ bool mouse_read = false; // (the menu has read the mouse since it opened) // (the Cheats page's and the list pages' record of each shown row's or line's value's left edge: the menu's width, none) int cheat_value_l[kCheatRows]; int list_value_l[kTeleportLines]; -bool is_slider(const Row& r) { return r.value == &opts.sharpness || r.value == &opts.mouse_sens || r.value == &opts.gyro_sens_x || r.value == &opts.gyro_sens_y; } +bool is_slider(const Row& r) { return r.value == &opts.sharpness || r.value == &opts.mouse_sens || r.value == &opts.gyro_sens_x || r.value == &opts.gyro_sens_y || r.value == &opts.volume; } /// (a scale: the arrows, a click and the wheel stop at its ends; Rumble strength would go from Off to its strongest) -bool stops_at_ends(const Row& r) { return r.value == &opts.mouse_sens || r.value == &opts.rumble || r.value == &opts.gyro_sens_x || r.value == &opts.gyro_sens_y; } +bool stops_at_ends(const Row& r) { return r.value == &opts.mouse_sens || r.value == &opts.rumble || r.value == &opts.gyro_sens_x || r.value == &opts.gyro_sens_y || r.value == &opts.volume; } /// (the ini's Rumble strength on the menu's steps: 0 to 150, the nearest 25) int rumble_step(int v) { return (std::min(std::max(v, 0), 150) + 12) / 25 * 25; } std::string ini_path() { @@ -1298,24 +1308,24 @@ void paint_menu(HDC dc, uint32_t w, uint32_t h, int s) { DeleteObject(body); return; } - // the tab strip: Graphics, Gameplay, Controls (Q and E, the bumpers or Tab change it: menu_tick) + // the tab strip: Graphics, Audio, Gameplay, Controls (Q and E, the bumpers or Tab change it: menu_tick) const int tab_y = menu_hit::kTabY * s; const int tab_h = menu_hit::kTabH * s; fill(dc, 0, tab_y, (int)w, tab_h, RGB(22, 28, 25)); HFONT small_font = font(12 * s, FW_SEMIBOLD); SelectObject(dc, small_font); - SetTextColor(dc, hover_tab == 3 ? RGB(210, 225, 215) : RGB(110, 135, 120)); // (lighter under the cursor: a click goes there) + SetTextColor(dc, hover_tab == kHoverPrev ? RGB(210, 225, 215) : RGB(110, 135, 120)); // (lighter under the cursor: a click goes there) TextOutA(dc, 16 * s, tab_y + 8 * s, "[Q / LB]", 8); const char* rb_hint = "[E / RB]"; SIZE rbsz{}; GetTextExtentPoint32A(dc, rb_hint, (int)strlen(rb_hint), &rbsz); - SetTextColor(dc, hover_tab == 4 ? RGB(210, 225, 215) : RGB(110, 135, 120)); + SetTextColor(dc, hover_tab == kHoverNext ? RGB(210, 225, 215) : RGB(110, 135, 120)); TextOutA(dc, (int)w - 16 * s - rbsz.cx, tab_y + 8 * s, rb_hint, (int)strlen(rb_hint)); HFONT tab_font = font(13 * s, FW_BOLD); SelectObject(dc, tab_font); - const char* const tab_titles[TAB_COUNT] = {"GRAPHICS", "GAMEPLAY", "CONTROLS"}; + const char* const tab_titles[TAB_COUNT] = {"GRAPHICS", "AUDIO", "GAMEPLAY", "CONTROLS"}; const int center_start = menu_hit::kTabSide * s; - const int tab_slot_w = menu_hit::tab_slot_w(s, (int)w); + const int tab_slot_w = menu_hit::tab_slot_w(s, (int)w, TAB_COUNT); for (int t = 0; t < TAB_COUNT; t++) { const bool active = (t == cur_tab); int slot_x = center_start + t * tab_slot_w; @@ -2112,8 +2122,8 @@ void menu_tick() { if (mouse) { // (the mouse: see the top of this file) const int count = tab_row_count(cur_tab); const menu_hit::Hit hit = menu_hit::options(mx, my, scale, mw, mh, TAB_COUNT, count, row_value_l, row_slider); - const int ht = hit.part == menu_hit::kTab ? hit.index : hit.part == menu_hit::kTabPrev ? 3 - : hit.part == menu_hit::kTabNext ? 4 : -1; + const int ht = hit.part == menu_hit::kTab ? hit.index : hit.part == menu_hit::kTabPrev ? kHoverPrev + : hit.part == menu_hit::kTabNext ? kHoverNext : -1; if (ht != hover_tab) hover_tab = ht, changed = true; int& sel = tab_selected[cur_tab]; const bool on_row = hit.part == menu_hit::kRow || hit.part == menu_hit::kSlider; diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 7f0264dcb..6a4f56617 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -43,6 +43,8 @@ struct Options { int preset = 0; // quality preset: 0 Custom, 1 Minimum (4GB), 2 Low (6GB), 3 Balanced (8GB), 4 High (12GB), 5 Ultra (16GB+) int vram_mb = 0; // dynamic texture cache budget limit in MB int mirror_mb = 0; // staging mirror limit in MB + int volume = 100; // (hookshot) the game's sound in percent, 0 to 100 (host/nn_audio.c, option 18) + int mute_background = 0; // (hookshot) no sound while another window has the focus (host/nn_audio.c, option 19) int mic = 1; // (hookshot) the ocarina's microphone: 0 Off, 1 the system's default, 2.. a device as host->mic_name lists it (host/nn_audio_in.c) }; extern Options opts; diff --git a/gpu/vk/vk_menu_hit.h b/gpu/vk/vk_menu_hit.h index 882913672..5fffcf4d6 100644 --- a/gpu/vk/vk_menu_hit.h +++ b/gpu/vk/vk_menu_hit.h @@ -57,7 +57,7 @@ inline void origin(int img_w, int img_h, int menu_w, int menu_h, int* x0, int* y *y0 = (img_h - menu_h) / 2; } inline int rows_top(int s) { return (kTabY + kTabH + kRowGap) * s; } -inline int tab_slot_w(int s, int w) { return (w - 2 * kTabSide * s) / 3; } +inline int tab_slot_w(int s, int w, int tabs) { return tabs > 0 ? (w - 2 * kTabSide * s) / tabs : 0; } /// A slider's value for a point along its track (lo to hi in steps of step), nearest step, held to its ends inline int slider_value(int permille, int lo, int hi, int step) { if (step <= 0 || hi <= lo) return lo; @@ -79,7 +79,7 @@ inline Hit options(int x, int y, int s, int w, int h, int tabs, int rows, const Hit hit; if (x < 0 || y < 0 || x >= w || y >= h) return hit.part = kOutside, hit; if (y >= kTabY * s && y < (kTabY + kTabH) * s) { - const int side = kTabSide * s, slot = tab_slot_w(s, w); + const int side = kTabSide * s, slot = tab_slot_w(s, w, tabs); if (x < side) hit.part = kTabPrev; else if (x >= w - side) hit.part = kTabNext; else if (slot > 0) { diff --git a/gpu/vk/vk_replay.cpp b/gpu/vk/vk_replay.cpp index e6b315d98..7beaee28b 100644 --- a/gpu/vk/vk_replay.cpp +++ b/gpu/vk/vk_replay.cpp @@ -3738,7 +3738,8 @@ void fill_replay_table(MkwVkRenderer& t) { return id == 0 ? opts.realtime : id == 1 ? menu_is_open() : id == 2 ? resolution_auto() && !dlss_full_scale() : id == 3 ? (dlss_full_scale() ? 10000 : resolution_scale()) : id == 4 ? opts.pad_background : id == 5 ? opts.text_speed : id == 6 ? opts.mouse_native : id == 7 ? opts.mouse_sens : id == 8 ? opts.mouse_invert : id == 9 ? opts.rumble : id == 16 ? opts.invert_cam_x : id == 17 ? opts.invert_cam_y : id == HOS_OPT_GYRO ? opts.gyro : id == HOS_OPT_GYRO_SENS_X ? opts.gyro_sens_x : id == HOS_OPT_GYRO_SENS_Y ? opts.gyro_sens_y : id == HOS_OPT_GYRO_INV_X ? opts.gyro_inv_x : id == HOS_OPT_GYRO_INV_Y ? opts.gyro_inv_y : id == HOS_OPT_DLSS_JITTER_W || id == HOS_OPT_DLSS_JITTER_H ? (int)dlss_jitter_size(id - HOS_OPT_DLSS_JITTER_W) : - id == HOS_OPT_DLSS_JITTER_PHASES ? (int)dlss_jitter_size(2) : 0; + id == HOS_OPT_DLSS_JITTER_PHASES ? (int)dlss_jitter_size(2) : + id == HOS_OPT_VOLUME ? opts.volume : id == HOS_OPT_MUTE_BACKGROUND ? opts.mute_background : 0; }; t.option_set = menu_option_set; t.nvn_register_sampler = register_sampler_queued; diff --git a/host/nn_audio.c b/host/nn_audio.c index ced34181a..bd762fcb2 100644 --- a/host/nn_audio.c +++ b/host/nn_audio.c @@ -16,6 +16,7 @@ HOS_AUDIO_DEVICE=: that waveOut device first; HOS_AUDIO_MUTE=1: the device fed at volume 0 (test runs); HOS_AUDIO_CHANNELS=2 or 6: stereo or 5.1 whatever the default output has (see au_speakers). */ #include "hos.h" +#include "../gpu/vk/vk_api.h" /* (the Audio tab's options: HOS_OPT_VOLUME, HOS_OPT_MUTE_BACKGROUND) */ #include #include #include @@ -1003,6 +1004,41 @@ static int64_t audio_mix_deadline(int64_t now, int64_t next, int64_t period) { return next; } +/* ---- the player's volume (the Insert menu's Audio tab, gpu/vk/vk_menu.cpp) ------------------------------------- */ +/* The frame's samples (frames x channels, interleaved) scaled from `from`, the gain the last frame ended at, to `want`, + step by step over the frame, so a change does not click. The gains are 0 to 1: nothing clips. Returns the gain the + frame ended at. tests/test_audio_volume.py */ +static float audio_apply_gain(int16_t *pcm, int frames, int channels, float from, float want) { + if (from == 1.0f && want == 1.0f) return want; + for (int i = 0; i < frames; i++) { + const float g = from + (want - from) * (float)(i + 1) / (float)frames; + for (int c = 0; c < channels; c++) pcm[i * channels + c] = (int16_t)lrintf((float)pcm[i * channels + c] * g); + } + return want; +} + +/* The game's window has the focus. An overlay in front (a tool or no-activate window: Steam's, Discord's) still counts + as the game's, as host/nn_hid.c's game_in_front has it. */ +static int audio_window_in_front(void) { + DWORD pid = 0; + HWND w = GetForegroundWindow(); + if (!w) return 0; + GetWindowThreadProcessId(w, &pid); + if (pid == GetCurrentProcessId()) return 1; + return (GetWindowLongPtrA(w, GWL_EXSTYLE) & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE)) != 0; +} + +/* The gain the menu asks for: Volume (0 to 100 percent) and Mute in background. With no menu + (the WebGPU renderer) the game plays at full volume. */ +static float audio_player_gain(void) { + int rx_option(int id, int otherwise); + int volume = rx_option(HOS_OPT_VOLUME, 100); + if (volume < 0) volume = 0; + if (volume > 100) volume = 100; + if (volume && rx_option(HOS_OPT_MUTE_BACKGROUND, 0) && !audio_window_in_front()) volume = 0; + return (float)volume / 100.0f; +} + /* ---- the renderer thread --------------------------------------------------------------------------------------- */ static uint64_t renderer_event; static volatile int renderer_running; @@ -1015,6 +1051,8 @@ static DWORD WINAPI renderer_main(void *p) { output_open(); int64_t period = 5000000, next = hos_now_ns() + period; /* one audio frame: 240 samples at 48 kHz */ static int16_t pcm[FRAME * OUT_MAX_CH]; + float gain = 1.0f, gain_want = 1.0f; + int gain_frames = 0; for (;;) { hos_sleep_ns(next - hos_now_ns()); next = audio_mix_deadline(hos_now_ns(), next, period); @@ -1023,6 +1061,8 @@ static DWORD WINAPI renderer_main(void *p) { if (ncmds && hos_now_ns() - last_update_ns > 50000000) commit(); /* (no update came: see "parameter updates") */ render_frame(pcm, out_channels); au_unlock(); + if (gain_frames-- <= 0) gain_want = audio_player_gain(), gain_frames = 20; /* (the menu read every 100 ms) */ + gain = audio_apply_gain(pcm, FRAME, out_channels, gain, gain_want); output_frame(pcm); elapsed_frames++; if (renderer_event) hos_system_event_signal(renderer_event); diff --git a/readme.md b/readme.md index b34b52506..be34b9406 100644 --- a/readme.md +++ b/readme.md @@ -228,6 +228,8 @@ choices and starts the game again, so progress since your last save is lost. | | Handheld profile | Off, On (after a restart) | Off | | | Ultrawide | 16:9, Monitor's shape (after a restart) | 16:9 | | | Robust buffers | Auto, Off, On (after a restart) | Auto | +| Audio | Volume | 0 to 100%, in steps of 5 | 100% | +| | Mute in background | Off, On | Off | | Gameplay | FPS counter | Off, On | Off | | | Game speed | Per frame, Real time | Real time | | | Text speed | Normal, 2x, 4x, Instant | Normal | @@ -277,6 +279,9 @@ choices and starts the game again, so progress since your last save is lost. - **Handheld profile** runs the game as if the console were undocked: lower resolution, lighter on the GPU. - **Robust buffers:** a read past a buffer's end is dropped instead of stopping the GPU. Auto is on, on every card; Off can be faster on some NVIDIA cards. +- **Volume** scales the game's whole sound, on top of the game's own sound settings and Windows' volume. **Mute in + background** silences the game while another window has the focus; an overlay such as Steam's or Discord's does not + count as another window. - **Game speed:** with Per frame, the game runs slower whenever it is below 60 fps. Real time keeps it at normal speed down to 15 fps. - **FPS counter** shows in the top right corner. With frame generation on, it also shows the game's own rate. diff --git a/tests/run_all.py b/tests/run_all.py index 1dc2e112c..a78aafc51 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -52,6 +52,7 @@ "tests/test_startup_progress.py": ["--cxx", "g++"], "tests/test_audio_voice_biquad.py": ["--cc", "gcc"], "tests/test_motion.py": ["--cc", "gcc"], + "tests/test_audio_volume.py": ["--cc", "gcc"], "tests/test_cpp_preflight.py": ["--cxx", "g++"], "tests/test_invalidate_vcore.py": ["--cxx", "g++"], "tests/test_linux_driver_fault_guard.py": ["--cxx", "{cxx}"], diff --git a/tests/test_audio_volume.py b/tests/test_audio_volume.py new file mode 100644 index 000000000..1857e30a6 --- /dev/null +++ b/tests/test_audio_volume.py @@ -0,0 +1,139 @@ +"""The Insert menu's Audio tab: Volume and Mute in background. + +host/nn_audio.c's audio_apply_gain is compiled from the source and run: full volume leaves the samples alone, a change +ramps over one frame (no click), the frame ends at the gain asked for, nothing clips, and every channel gets the same +gain. The mixer applies it to each frame before the output. The option ids the host reads (HOS_OPT_VOLUME and +HOS_OPT_MUTE_BACKGROUND) are checked against the renderer's option table (gpu/vk/vk_replay.cpp) and the menu's rows and +defaults. + + python -I -B tests/test_audio_volume.py [--cc gcc] +""" +import argparse +import os +import re +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read(*parts): + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def block(text, at): + """text from at to the end of the first {...} after it""" + start = text.index("{", at) + depth = 0 + for i in range(start, len(text)): + depth += text[i] == "{" + depth -= text[i] == "}" + if depth == 0: + return text[at:i + 1] + raise SystemExit("no end for the block at %d" % at) + + +FIXTURE = r""" +#include +#include +#include +#include +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL %%d %%s\n", __LINE__, #x); exit(1); } } while (0) +%s +static void fill(int16_t *pcm, int n, int16_t v) { for (int i = 0; i < n; i++) pcm[i] = (int16_t)(i & 1 ? -v : v); } +int main(void) { + enum { FRAMES = 240, CH = 2 }; + static int16_t pcm[FRAMES * CH]; + /* full volume: the samples as they were, the extremes too */ + fill(pcm, FRAMES * CH, 32767); + pcm[1] = -32768; + CHECK(audio_apply_gain(pcm, FRAMES, CH, 1.0f, 1.0f) == 1.0f); + CHECK(pcm[0] == 32767 && pcm[1] == -32768 && pcm[2] == 32767); + /* 100%% to 0: a ramp down over the frame (no jump at its start), silent at its end */ + fill(pcm, FRAMES * CH, 20000); + CHECK(audio_apply_gain(pcm, FRAMES, CH, 1.0f, 0.0f) == 0.0f); + CHECK(pcm[0] > 19800); + CHECK(pcm[(FRAMES - 1) * CH] == 0 && pcm[(FRAMES - 1) * CH + 1] == 0); + for (int i = 1; i < FRAMES; i++) CHECK(pcm[i * CH] <= pcm[(i - 1) * CH]); + /* both channels get one gain (left positive, right negative here) */ + for (int i = 0; i < FRAMES; i++) CHECK(pcm[i * CH] == -pcm[i * CH + 1]); + /* held at 50%%: every sample halved */ + fill(pcm, FRAMES * CH, 20000); + CHECK(audio_apply_gain(pcm, FRAMES, CH, 0.5f, 0.5f) == 0.5f); + for (int i = 0; i < FRAMES * CH; i++) CHECK(abs(pcm[i]) == 10000); + /* 0 back up to 100%%: no sample past what it was (gains up to 1: nothing clips) */ + fill(pcm, FRAMES * CH, -32768); + CHECK(audio_apply_gain(pcm, FRAMES, CH, 0.0f, 1.0f) == 1.0f); + for (int i = 0; i < FRAMES * CH; i++) CHECK(pcm[i] >= -32768 && pcm[i] <= 32767); + CHECK(pcm[(FRAMES - 1) * CH] == -32768); + /* six channels: the same */ + static int16_t six[FRAMES * 6]; + fill(six, FRAMES * 6, 1000); + audio_apply_gain(six, FRAMES, 6, 0.25f, 0.25f); + for (int i = 0; i < FRAMES * 6; i++) CHECK(abs(six[i]) == 250); + return 0; +} +""" + + +def source_checks(): + audio = read("host", "nn_audio.c") + loop = audio[audio.index("static DWORD WINAPI renderer_main"):audio.index("/* ---- renderer lifecycle")] + gain_at, out_at = loop.find("audio_apply_gain("), loop.find("output_frame(pcm)") + if gain_at < 0 or out_at < 0 or gain_at > out_at: + raise SystemExit("nn_audio.c: the mixer no longer applies the player's volume before the output") + player = block(audio, audio.index("static float audio_player_gain(void)")) + if "rx_option(HOS_OPT_VOLUME, 100)" not in player or "rx_option(HOS_OPT_MUTE_BACKGROUND, 0)" not in player: + raise SystemExit("nn_audio.c: the volume is not read as HOS_OPT_VOLUME (default 100) and HOS_OPT_MUTE_BACKGROUND (off)") + replay, api = read("gpu", "vk", "vk_replay.cpp"), read("gpu", "vk", "vk_api.h") + value = {} # (every HOS_OPT_* enum: the cheats', the motion rows', the Audio tab's) + for first, start, rest in re.findall(r"enum \{ (HOS_OPT_\w+) = (\d+), ([^}]*)\}", api): + names = [first] + [n.strip() for n in rest.split(",") if n.strip()] + value.update({n: int(start) + i for i, n in enumerate(names)}) + table = block(replay, replay.index("t.option = [](int id) -> int {")) + ids = [int(n) if n.isdigit() else value[n] for n in re.findall(r"\bid == (\w+)", table)] + if len(ids) != len(set(ids)): + raise SystemExit("vk_replay.cpp: an option id is read twice: %s" % sorted(ids)) + for name, field in (("HOS_OPT_VOLUME", "volume"), ("HOS_OPT_MUTE_BACKGROUND", "mute_background")): + if not re.search(r"\bid == %s \? opts\.%s\b" % (name, field), table): + raise SystemExit("vk_replay.cpp: %s is not the menu's opts.%s" % (name, field)) + header, menu = read("gpu", "vk", "vk_menu.h"), read("gpu", "vk", "vk_menu.cpp") + for default in ("int volume = 100;", "int mute_background = 0;"): + if default not in header: + raise SystemExit("vk_menu.h: out of the box the Audio tab is not 100% and not muted in background") + if not re.search(r'\{TAB_AUDIO, "Volume", &opts\.volume, 0, 100, 5, tx_percent,', menu): + raise SystemExit("vk_menu.cpp: the Audio tab lost its Volume row (0 to 100%, steps of 5)") + if not re.search(r'\{TAB_AUDIO, "Mute in background", &opts\.mute_background, 0, 1, 1, tx_onoff,', menu): + raise SystemExit("vk_menu.cpp: the Audio tab lost its Mute in background row") + titles = re.search(r"tab_titles\[TAB_COUNT\] = \{([^}]*)\}", menu).group(1) + if '"AUDIO"' not in titles: + raise SystemExit("vk_menu.cpp: the tab strip has no AUDIO title") + for name in ("HOS_OPT_VOLUME", "HOS_OPT_MUTE_BACKGROUND"): + users = [] + for folder in ("host", "gpu"): + for path in (ROOT / folder).rglob("*.c*"): + text = path.read_text(encoding="utf-8", errors="replace") + if re.search(r"\brx_option(_set)?\(\s*(%s|%d)\s*," % (name, value[name]), text): + users.append(path.relative_to(ROOT).as_posix()) + if users != ["host/nn_audio.c"]: + raise SystemExit("%s is read or set outside host/nn_audio.c: %s" % (name, users)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cc", default=os.environ.get("CC", "gcc")) + args = parser.parse_args() + source_checks() + audio = read("host", "nn_audio.c") + fn = block(audio, audio.index("static float audio_apply_gain(")) + with tempfile.TemporaryDirectory(prefix="audio-volume-") as tmp: + src, exe = Path(tmp) / "audio_volume.c", Path(tmp) / "audio_volume" + src.write_text(FIXTURE % fn, encoding="utf-8") + subprocess.run([args.cc, "-std=c11", "-Wall", "-Wextra", "-Werror", str(src), "-o", str(exe), "-lm"], check=True) + subprocess.run([str(exe)], check=True) + print("PASS: the volume ramps over a frame without clipping, and the Audio tab's options are its own") + + +if __name__ == "__main__": + main() diff --git a/tests/test_menu_hit.cpp b/tests/test_menu_hit.cpp index 90e4de08f..7dd9a1ebc 100644 --- a/tests/test_menu_hit.cpp +++ b/tests/test_menu_hit.cpp @@ -32,6 +32,14 @@ int main() { CHECK("[Q / LB] is the previous tab", at(40, 60, 1).part == kTabPrev); CHECK("[E / RB] is the next tab", at(450, 60, 1).part == kTabNext); CHECK("the title is no control", at(100, 20, 1).part == kNone); + // four tabs (Graphics, Audio, Gameplay, Controls): slots of 77 from 85 + auto tab4 = [&](int x) { return options(x, 60, 1, w1, h1, 4, 11, value_l, slider); }; + CHECK("4 tabs: tab 0", tab4(100).part == kTab && tab4(100).index == 0); + CHECK("4 tabs: tab 1", tab4(200).part == kTab && tab4(200).index == 1); + CHECK("4 tabs: tab 2", tab4(250).part == kTab && tab4(250).index == 2); + CHECK("4 tabs: tab 3", tab4(350).part == kTab && tab4(350).index == 3); + CHECK("4 tabs: the last slot's slack is tab 3", tab4(394).part == kTab && tab4(394).index == 3); + CHECK("4 tabs: [E / RB]", tab4(395).part == kTabNext); // ---- scale 1: the rows (from 84, 28 high) ---- h = at(30, 84 + 3 * 28 + 10, 1); From 5976ed63669b3924a4e3e9db5b8f2d97ed6f2893 Mon Sep 17 00:00:00 2001 From: bvtterbroot <157503259+bvtterbroot@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:46:08 +0100 Subject: [PATCH 2/2] fix: Audio tab review: overlays unmute only while the game was in front, a squared volume curve - Mute in background: a tool or no-activate window in front counts as the game's only while the last normal window in front was the game's, as host/nn_hid.c's game_in_front decides it (the mixer keeps its own state). A toast or a tray flyout no longer brings the sound back while the game is behind another window. - Volume follows a squared curve: 50% is a quarter (-12 dB), so the bottom half of the slider is no longer nearly all very quiet. - The tab strip's ends are 70 wide (their hints are ~50 from 16 in), so the four tabs get slots of 85 at scale 1: GAMEPLAY's highlight (13 px bold) left CONTROLS' title 1.5 px at 77. - vk_menu.h's comments name the right option ids (27, 28). Co-Authored-By: Claude Opus 5.5 --- gpu/vk/vk_menu.h | 4 ++-- gpu/vk/vk_menu_hit.h | 4 +++- host/nn_audio.c | 32 ++++++++++++++++++++++++-------- readme.md | 3 ++- tests/test_audio_volume.py | 23 ++++++++++++++++++++--- tests/test_menu_hit.cpp | 9 +++++---- 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 6a4f56617..3c5686f70 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -43,8 +43,8 @@ struct Options { int preset = 0; // quality preset: 0 Custom, 1 Minimum (4GB), 2 Low (6GB), 3 Balanced (8GB), 4 High (12GB), 5 Ultra (16GB+) int vram_mb = 0; // dynamic texture cache budget limit in MB int mirror_mb = 0; // staging mirror limit in MB - int volume = 100; // (hookshot) the game's sound in percent, 0 to 100 (host/nn_audio.c, option 18) - int mute_background = 0; // (hookshot) no sound while another window has the focus (host/nn_audio.c, option 19) + int volume = 100; // (hookshot) the game's sound in percent, 0 to 100 (host/nn_audio.c: HOS_OPT_VOLUME, 27) + int mute_background = 0; // (hookshot) no sound while another window has the focus (host/nn_audio.c: HOS_OPT_MUTE_BACKGROUND, 28) int mic = 1; // (hookshot) the ocarina's microphone: 0 Off, 1 the system's default, 2.. a device as host->mic_name lists it (host/nn_audio_in.c) }; extern Options opts; diff --git a/gpu/vk/vk_menu_hit.h b/gpu/vk/vk_menu_hit.h index 5fffcf4d6..e294e35d4 100644 --- a/gpu/vk/vk_menu_hit.h +++ b/gpu/vk/vk_menu_hit.h @@ -7,7 +7,9 @@ namespace mkwvk::menu_hit { // ---- the options page, at a scale of 1 (the window 720 pixels high; 1440 and up: 2, and so on) ---- constexpr int kWidth = 480; constexpr int kTabY = 44, kTabH = 32; // the tab strip -constexpr int kTabSide = 85; // either side of the tab titles: [Q / LB] and [E / RB], the previous and next tab +constexpr int kTabSide = 70; // either side of the tab titles: [Q / LB] and [E / RB], the previous and next tab + // (their text ~50 from 16 in; 70 leaves the four tabs slots of 85, room for GAMEPLAY's + // and CONTROLS' highlight at 13 px bold) constexpr int kRowGap = 8, kRowH = 28; // the rows, from kTabY + kTabH + kRowGap constexpr int kRowX = 8; // a row's band, kRowX in from either side constexpr int kValueRight = 18; // a value ends kValueRight from the right diff --git a/host/nn_audio.c b/host/nn_audio.c index bd762fcb2..48ce3a4c3 100644 --- a/host/nn_audio.c +++ b/host/nn_audio.c @@ -1017,26 +1017,42 @@ static float audio_apply_gain(int16_t *pcm, int frames, int channels, float from return want; } -/* The game's window has the focus. An overlay in front (a tool or no-activate window: Steam's, Discord's) still counts - as the game's, as host/nn_hid.c's game_in_front has it. */ -static int audio_window_in_front(void) { +/* Whether the game's window counts as in front, as host/nn_hid.c's game_in_front decides it: the game's own window does, + another normal window does not, and an overlay (a tool or no-activate window: Steam's, Discord's, but also a toast or + a tray flyout) counts as the game's only while the last normal window in front was the game's. *last_normal_was_us + keeps that between calls. tests/test_audio_volume.py */ +static int audio_front_decide(int ours, int overlay, int *last_normal_was_us) { + if (ours) return *last_normal_was_us = 1; + if (overlay && *last_normal_was_us) return 1; + *last_normal_was_us = 0; + return 0; +} +static int audio_window_in_front(void) { /* (the mixer thread's only: its own state, not game_in_front's) */ + static int last_normal_was_us = 1; DWORD pid = 0; HWND w = GetForegroundWindow(); if (!w) return 0; GetWindowThreadProcessId(w, &pid); - if (pid == GetCurrentProcessId()) return 1; - return (GetWindowLongPtrA(w, GWL_EXSTYLE) & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE)) != 0; + const int ours = pid == GetCurrentProcessId(); + const int overlay = !ours && (GetWindowLongPtrA(w, GWL_EXSTYLE) & (WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE)) != 0; + return audio_front_decide(ours, overlay, &last_normal_was_us); } -/* The gain the menu asks for: Volume (0 to 100 percent) and Mute in background. With no menu - (the WebGPU renderer) the game plays at full volume. */ +/* The Volume row's percent (0 to 100) as a gain (0 to 1), on a squared curve so the slider's steps sound about even: 50% + is a quarter, -12 dB. A straight line left the bottom half of the slider nearly all very quiet. */ +static float audio_volume_curve(int percent) { + const float v = (float)(percent < 0 ? 0 : percent > 100 ? 100 : percent) / 100.0f; + return v * v; +} +/* The gain the menu asks for: Volume and Mute in background. With no menu (the WebGPU renderer) the game plays at full + volume. */ static float audio_player_gain(void) { int rx_option(int id, int otherwise); int volume = rx_option(HOS_OPT_VOLUME, 100); if (volume < 0) volume = 0; if (volume > 100) volume = 100; if (volume && rx_option(HOS_OPT_MUTE_BACKGROUND, 0) && !audio_window_in_front()) volume = 0; - return (float)volume / 100.0f; + return audio_volume_curve(volume); } /* ---- the renderer thread --------------------------------------------------------------------------------------- */ diff --git a/readme.md b/readme.md index be34b9406..69c0fdb77 100644 --- a/readme.md +++ b/readme.md @@ -279,7 +279,8 @@ choices and starts the game again, so progress since your last save is lost. - **Handheld profile** runs the game as if the console were undocked: lower resolution, lighter on the GPU. - **Robust buffers:** a read past a buffer's end is dropped instead of stopping the GPU. Auto is on, on every card; Off can be faster on some NVIDIA cards. -- **Volume** scales the game's whole sound, on top of the game's own sound settings and Windows' volume. **Mute in +- **Volume** scales the game's whole sound, on top of the game's own sound settings and Windows' volume. Its steps + follow a curve so they sound about even: 50% is a quarter of the level (-12 dB). **Mute in background** silences the game while another window has the focus; an overlay such as Steam's or Discord's does not count as another window. - **Game speed:** with Per frame, the game runs slower whenever it is below 60 fps. Real time keeps it at normal speed diff --git a/tests/test_audio_volume.py b/tests/test_audio_volume.py index 1857e30a6..8b55b3cc3 100644 --- a/tests/test_audio_volume.py +++ b/tests/test_audio_volume.py @@ -2,7 +2,9 @@ host/nn_audio.c's audio_apply_gain is compiled from the source and run: full volume leaves the samples alone, a change ramps over one frame (no click), the frame ends at the gain asked for, nothing clips, and every channel gets the same -gain. The mixer applies it to each frame before the output. The option ids the host reads (HOS_OPT_VOLUME and +gain. The mixer applies it to each frame before the output. The Volume row's curve is squared (50% is -12 dB), and Mute +in background's foreground check lets an overlay (a toast, a tray flyout) count as the game's only while the game was +the last normal window in front, as host/nn_hid.c's game_in_front does. The option ids the host reads (HOS_OPT_VOLUME and HOS_OPT_MUTE_BACKGROUND) are checked against the renderer's option table (gpu/vk/vk_replay.cpp) and the menu's rows and defaults. @@ -72,6 +74,19 @@ def block(text, at): fill(six, FRAMES * 6, 1000); audio_apply_gain(six, FRAMES, 6, 0.25f, 0.25f); for (int i = 0; i < FRAMES * 6; i++) CHECK(abs(six[i]) == 250); + /* the Volume row's curve: squared, 50%% a quarter (-12 dB), held to 0..100, rising */ + CHECK(audio_volume_curve(100) == 1.0f && audio_volume_curve(0) == 0.0f); + CHECK(fabsf(audio_volume_curve(50) - 0.25f) < 1e-6f); + CHECK(audio_volume_curve(150) == 1.0f && audio_volume_curve(-5) == 0.0f); + for (int v = 5; v <= 100; v += 5) CHECK(audio_volume_curve(v) > audio_volume_curve(v - 5)); + /* in front: the game's window yes, another normal window no; an overlay (a toast, a tray flyout, Steam's) only while + the last normal window in front was the game's */ + int last = 1; + CHECK(audio_front_decide(1, 0, &last) == 1 && last == 1); + CHECK(audio_front_decide(0, 1, &last) == 1); /* (Steam's overlay over the game) */ + CHECK(audio_front_decide(0, 0, &last) == 0 && last == 0); /* (alt-tab to a browser) */ + CHECK(audio_front_decide(0, 1, &last) == 0); /* (a toast while the game is behind: still muted) */ + CHECK(audio_front_decide(1, 0, &last) == 1 && last == 1); /* (back to the game) */ return 0; } """ @@ -126,13 +141,15 @@ def main(): args = parser.parse_args() source_checks() audio = read("host", "nn_audio.c") - fn = block(audio, audio.index("static float audio_apply_gain(")) + fn = "\n".join(block(audio, audio.index(head)) for head in ( + "static float audio_apply_gain(", "static float audio_volume_curve(", "static int audio_front_decide(")) with tempfile.TemporaryDirectory(prefix="audio-volume-") as tmp: src, exe = Path(tmp) / "audio_volume.c", Path(tmp) / "audio_volume" src.write_text(FIXTURE % fn, encoding="utf-8") subprocess.run([args.cc, "-std=c11", "-Wall", "-Wextra", "-Werror", str(src), "-o", str(exe), "-lm"], check=True) subprocess.run([str(exe)], check=True) - print("PASS: the volume ramps over a frame without clipping, and the Audio tab's options are its own") + print("PASS: the volume ramps over a frame without clipping on a squared curve, a toast behind the game does not " + "unmute it, and the Audio tab's options are its own") if __name__ == "__main__": diff --git a/tests/test_menu_hit.cpp b/tests/test_menu_hit.cpp index 7dd9a1ebc..f4a59908a 100644 --- a/tests/test_menu_hit.cpp +++ b/tests/test_menu_hit.cpp @@ -22,7 +22,7 @@ int main() { }; Hit h; - // ---- scale 1: the tabs (side 85, slots of 103 from 85) ---- + // ---- scale 1: the tabs (side 70, slots of 113 from 70) ---- h = at(100, 60, 1); CHECK("tab 0", h.part == kTab && h.index == 0); h = at(240, 60, 1); @@ -32,14 +32,15 @@ int main() { CHECK("[Q / LB] is the previous tab", at(40, 60, 1).part == kTabPrev); CHECK("[E / RB] is the next tab", at(450, 60, 1).part == kTabNext); CHECK("the title is no control", at(100, 20, 1).part == kNone); - // four tabs (Graphics, Audio, Gameplay, Controls): slots of 77 from 85 + // four tabs (Graphics, Audio, Gameplay, Controls): slots of 85 from 70 auto tab4 = [&](int x) { return options(x, 60, 1, w1, h1, 4, 11, value_l, slider); }; + CHECK("4 tabs: [Q / LB] up to 70", tab4(69).part == kTabPrev && tab4(70).part == kTab && tab4(70).index == 0); CHECK("4 tabs: tab 0", tab4(100).part == kTab && tab4(100).index == 0); CHECK("4 tabs: tab 1", tab4(200).part == kTab && tab4(200).index == 1); CHECK("4 tabs: tab 2", tab4(250).part == kTab && tab4(250).index == 2); CHECK("4 tabs: tab 3", tab4(350).part == kTab && tab4(350).index == 3); - CHECK("4 tabs: the last slot's slack is tab 3", tab4(394).part == kTab && tab4(394).index == 3); - CHECK("4 tabs: [E / RB]", tab4(395).part == kTabNext); + CHECK("4 tabs: the last slot ends at 410", tab4(409).part == kTab && tab4(409).index == 3); + CHECK("4 tabs: [E / RB]", tab4(410).part == kTabNext); // ---- scale 1: the rows (from 84, 28 high) ---- h = at(30, 84 + 3 * 28 + 10, 1);