diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index ab699f59..9559f7d4 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 fa8e940d..60e9d100 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 7f0264dc..3c5686f7 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: 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 88291367..e294e35d 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 @@ -57,7 +59,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 +81,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 e6b315d9..7beaee28 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 ced34181..48ce3a4c 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,57 @@ 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; +} + +/* 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); + 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 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 audio_volume_curve(volume); +} + /* ---- the renderer thread --------------------------------------------------------------------------------------- */ static uint64_t renderer_event; static volatile int renderer_running; @@ -1015,6 +1067,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 +1077,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 b34b5250..69c0fdb7 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,10 @@ 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. 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 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 1dc2e112..a78aafc5 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 00000000..8b55b3cc --- /dev/null +++ b/tests/test_audio_volume.py @@ -0,0 +1,156 @@ +"""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 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. + + 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); + /* 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; +} +""" + + +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 = "\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 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__": + main() diff --git a/tests/test_menu_hit.cpp b/tests/test_menu_hit.cpp index 90e4de08..f4a59908 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,6 +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 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 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);