Skip to content

Action audio: design and verification

Player controls are on Options page 2. This note distinguishes measured DSP behavior from live listening, which still needs a desktop playtest.

22 October 2026 strength follow-ups: at the maintainer's request, only the chorus return amplitude is doubled (+100%, +6.0206 dB of generated Side), and the bass slider now spans 0 to +20 dB (midpoint +10 dB). The existing chorus curve, modulation, routing, filter and limiter remain unchanged. These are not whole-music gain increases. The bass follow-up's current 96 kHz regression failure is documented below; its test threshold was not relaxed.

Signal path and safety

The existing capture/Koi-Koi envelope in src/ui_board.c still drives the effect. Its strength ramps up, can build across successive actions, and fades. The new sliders set the maximum amounts, not a permanent effect on all music. The page-2 Test Effect switch explicitly auditions that maximum: it routes a match track through this same callback with the action target held at 1 (ON) or released to 0 (OFF). OFF retains the same stream/position for dry comparison, not a second sample or a simulated filter. Leaving page 2 restores menu playback. No audition state is saved. Menu music has no attached processor. Ordinary card sound files are not replaced or routed into this processor.

The callback in src/ui_audio.c uses fixed-size buffers for a room/hall tank and a separate chorus. Processing stays local to that callback:

  1. Bass: an RBJ +10 dB prototype low shelf, parallel-scaled to a +20 dB low-frequency endpoint at the maximum slider. A smoothly blended shelf gives the selected low-frequency gain, multiplied by the action envelope; the slider midpoint requests +10 dB. Only the added shelf component passes through a stereo-linked compressor: maximum ratio 4:1, threshold 0.35 linear peak, nominal 5 ms attack / 120 ms release. It is then summed with the untouched original music. This replaces the old 8:1 whole-track compressor, which could make music quieter. +20 dB is the small-signal endpoint: already-loud bass gets a controlled boost, not a promise of 20 dB more output regardless of headroom.
  2. Space reverb: a Schroeder/Moorer-style room/hall tank: eight parallel damped combs and four serial true all-pass diffusers per channel, replacing the former four-comb/two-diffuser/slapback design. Independently chosen delay tunings use bounded, linearly interpolated taps. The room slider scales comb/diffuser delay lengths by 0.65–2.2×, predelay by approximately 5–50 ms, feedback by 0.55–0.93, and diffuser coefficient by 0.50–0.65. Raising it therefore expands reflection spacing and decay, not merely wet level. Dimensions follow the saved room control, not the capture envelope; repeatedly taking cards does not repeatedly sweep the delay lengths. Diffuser feedback remains below unity; comb state is bounded. Fixed low input gain reserves feedback headroom; reciprocal return staging restores the level without a signal-driven gain controller. The return retains its high-pass until the last 20%, when low-frequency ambience opens in Mid. The Side return stays high-passed to keep deep room bass centred rather than letting a low-frequency tank resonance pull the whole track to one ear. Higher room values also open the return bandwidth and increase the wet mix. The existing input-driven ducking affects only the shared reverb-return gain, not panning or chorus gain. Room-size changes interpolate delay taps and may produce a glide while dragging; settled room settings have no room-size oscillator. This is algorithmic reverb, not convolution, shimmer, or a physical model promising exact dimensions in metres.
  3. Chorus, separate after the room: the existing two fractional-delay trajectories retain their nominal 15/23 ms taps, up to 6 ms modulation and 0.60 Hz rate. Each voice now reads the mean of the two input channels: va=(tapL(d1)+tapR(d1))/2, vb=(tapL(d2)+tapR(d2))/2. The return is S = 2 × amount × HP((va−vb)/2), using a new state for the unchanged nominal 300 Hz high-pass. Add S to L and subtract exactly the same S from R. Thus (L+R)/2 is unchanged by the chorus encode, apart from float rounding; the original input Side is retained and generated Side is added. Each half factor is M/S algebra, not normalization tuning. No pre-scaling, feedback, tap/LFO/depth/rate or other DSP constant change was made. The high-pass has IIR decay, not delay-line feedback. The processing order remains Bass → Space reverb → Chorus → linked peak limiter. The identity concerns the chorus input/output before limiting, not unprocessed music with bass/room enabled; active shared limiting can change Mid. Generated Side has a fixed orientation and no longer commutes with L/R exchange.
  4. Output protection: the added-bass compressor is stereo-linked. A final soft-knee limiter uses one shared gain for both output channels, preserving their instantaneous ratio. It starts at 0.88 and bounds active output below 0.98 full scale. The old whole-track bus compressor was removed. This leaves more headroom for boosted bass without sustained whole-track ducking; near-full-scale input can still require peak limiting. With no action, or all controls at zero after their ramps settle, the output is bit-identical to the input with zero added latency, including hot samples and signed zero.

The UI publishes all three controls as one lock-free atomic integer snapshot (C99 GCC/Clang builtins); the audio thread owns the filters, smoothing, and delay lines. Track-transition resets are requested atomically and performed on the audio thread. There is no allocation, file access, new dependency, or UI-thread filter mutation in the callback. Existing BGM/SFX volume controls and the action trigger logic are unchanged.

Device-rate limitation, explicitly checked

Raylib 5.5 invokes stream processors after conversion to the output device's float-stereo format, before volume/mixing. The MP3's Music.stream.sampleRate is therefore not the processor's sample rate. Raylib's public API does not expose the device rate. We do not incorrectly substitute the source file rate.

To avoid changing or rebuilding the game's Raylib dependency, this change retains the engine's sample-domain delay lengths and nominal 44.1 kHz tuning. The bass shelf midpoint is nominally 140 Hz and return high-pass nominally 300 Hz; actual cutoffs scale with device rate, and actual delay/attack/release times scale inversely. The chorus LFO rate also scales with device rate (0.60/0.653/1.306 Hz at 44.1/48/96 kHz), rather than falsely treating the MP3 rate as the device rate. The tests exercise 44.1, 48, and 96 kHz device-frame sequences. Chorus routing, mono compatibility, and the low-frequency bass endpoint are checked at all three rates, but the current 96 kHz run fails the separate 8 kHz bass-leakage assertion described below. This is not a claim of identical frequency curves or reverb time on every device. Exact rate-invariant timing would require exposing/configuring the Raylib device rate in a separate dependency change.

Checks run (22 October 2026)

./tests/test_audio_dsp.sh runs the previous presence-band/SFX checks without weakening their thresholds, followed by tests/test_action_audio.c, which processes actual sample arrays through the real callback.

Current result (22 October 2026): the 500-match engine suite, settings checks, presence-band check, capture-SFX check, and the 44.1/48 kHz action-audio runs pass. At 96 kHz, the +20 dB bass run measures +0.054 dB at 8 kHz, exceeding the existing 0.05 dB limit, so tests/test_action_audio.c aborts at its bass assertion. Consequently the full ./build.sh test and the current Docs & Web Build CI job fail before packaging. The threshold and DSP were deliberately left unchanged in this documentation update.

The checks and measurements below distinguish those passing results from the open failure:

  • Bit-identical zero-latency bypass, including hot input and signed zero, after action release and with all controls off. Chunked callback tests retain sample-for-sample equivalence.
  • Chorus routing properties: mono and stereo quiet inputs preserve their original mono sum within 1×10⁻⁶ at 25/50/75/100%; mono Side/input RMS exceeds 0.1×amount and increases monotonically; mono-noise L/R balance stays within 0.1 dB; OFF is byte-identical; maximum mono-noise correlation is below 0.9. All five pass at 44.1/48/96 kHz. On 39876c3, sum, Side generation and decorrelation fail; balance/OFF already pass. The obsolete channel-swap, identical-ear impulse/noise and fixed-4:1-pan assertions encoded the rejected mono-identical decision and were explicitly removed/replaced, not silently relaxed.
  • chorus_wet_level_regression_snapshot: after the authorized 2× return, normalized wet/dry RMS at maximum is 1.143562 at 44.1 kHz (previously 0.571781). Across tested rates/amounts it spans 1.141175–1.146411. Snapshot bounds are explicitly scaled by exactly two, 0.50–0.75 → 1.00–1.50, not re-fitted to measurements; this remains change detection, not a correctness requirement. The exact-gain impulse test separately requires 2× the original filtered return.
  • Chorus modulation/history: an impulse creates delayed Side, and the tone waveform varies with modulation. Because the HP has a decaying IIR response, exact-zero output after 40 ms is no longer the no-feedback criterion: the actual delay histories must be zero after the impulse clears them. No fixed pan ratio is promised once generated Side is present.
  • Actual room growth: impulse tests require both first-reflection time and normalized decay duration to increase at 25/50/100%. Reverse-integrated wet impulse energy (excluding the dry impulse), extrapolated from −30 dB to T60, measures approximately 0.61 / 1.12 / 5.95 s at 44.1 kHz, 0.56 / 1.03 / 5.46 s at 48 kHz, and 0.28 / 0.52 / 2.73 s at 96 kHz. These are measured broadband impulse estimates, not frequency-independent decay guarantees or live listening. First reflection grows from 16.21 to 49.89 ms at 44.1 kHz. Normalized late/early energy increases too, so simply turning wet gain up cannot pass this test.
  • Bass after the +20 dB follow-up: quiet 20 Hz input at maximum measures +20.020 / +20.017 / +20.005 dB at 44.1/48/96 kHz; the midpoint measures +9.995 / +9.997 / +10.000 dB. Loud 20 Hz input (0.8 amplitude) gains +3.236 / +3.237 / +3.254 dB, rather than the historical approximately −7 dB. The 8 kHz measurements are +0.010 / +0.012 / +0.054 dB: the first two pass the unchanged 0.05 dB bound, while 96 kHz fails it. At 44.1/48 kHz, a high-frequency tone immediately after a loud kick stays within 0.006 dB of its original level, checking that the bass compressor no longer holds down the rest of the music; the 96 kHz assertion aborts before that later check runs. Channel-ratio tests verify linked rather than independent-ear compression.
  • Loud boosted-bass/full-room feedback states stay below the internal safety clamp, guarding against distortion inside the tail. Instrumented offline processing of both shipped MP3 excerpts confirmed zero internal tank-clamp events in the tested modes.
  • Full-room 40 Hz mono L/R balance stays within 0.35 dB at the three tested rates, guarding against deep reverb bass pulling the image.
  • A 40 Hz burst's tail at 100% room is 38.5–49.8 dB stronger than at 80%, checking late admission of bass. The bass is present in the full-band centre of the room return, not exaggerated in opposite-polarity Side.
  • Earlier passing runs, before the +20 dB follow-up: finite/bounded output under full-scale noise, smoothed slider steps, variable callback chunk sizes, transition resets, invalid-control handling, and AddressSanitizer/UndefinedBehaviorSanitizer checks. Those checks occur after the failing 96 kHz assertion and are not claimed as a new passing result for the current full suite.
  • The old presence-band regression now measures approximately −11.1 dB Side/Mid, passing its unchanged −12.5 dB minimum. Previously it failed at −18.7 dB with the unequal-ear decode.

Historical measurements on 39876c3, not current-routing validation: Additional offline checks decoded the first 30 seconds each of shipped game1.mp3 and game3.mp3 with Raylib's bundled dr_mp3, and ran chorus-only, bass-only and all-maximum settings through the callback. Both decoded as 44.1 kHz stereo. All output stayed finite and below 0.98 full scale. All-maximum peaks were 0.96687 / 0.97471, with overall L/R balance −0.370 / −0.180 dB. Chorus-only balance was −0.162 / −0.038 dB (source −0.161 / −0.031 dB). Low-level chorus mono-fold commutation error was below 2×10⁻⁹: folding the processed stereo matches processing the original mono fold-down, not the unchanged original waveform. Bass-only same-window overall RMS gains remain +0.058 / +5.902 dB, and four-pole 140 Hz filtered-band gains +5.422 / +6.425 dB. These are not LUFS measurements. Offline callback CPU cost was approximately 0.27–0.34 s per 30 s of audio in this sandbox, not a hardware-wide guarantee. In additional level-matched quiet-input comparisons with Reverb/Bass off, the isolated maximum chorus layer rises from −12.32 / −13.06 dB below dry to −2.96 / −2.60 dB on game1/game3. The same input frames and no active limiter were used, and zero chorus produced zero difference. These are numerical checks, not a claim of headphone/speaker listening.

The same post-loop section covers audition ON/OFF target values, actual wet sample changes versus exact dry samples after release, no track restart on a toggle, end-of-track advance, unavailable-music fallback, and automatic exit on page navigation/Back; it is likewise not reached by the current failing full run. Pagination, slider bounds, modal click blocking, save/load and bad settings are covered independently by ./tests/test_music_folder.sh, which passes. Live layout, perceived strength on different music/headphones, and complete Windows/web builds remain to verify.

Historical routing-only reproduction (22 October 2026, before the subsequent 2× return)

Seed-17 uniform noise ±0.05, 3 seconds, last 2 seconds measured after 1 second silent warmup; 44.1 kHz, bass/room zero, action one. Actual measured results, not the maintainer's seed-unspecified numbers:

Amount Mono change (dB) Max mono error Side/input RMS Peak
0.00 +0.000000 0 0.000000 0.050000
0.25 -0.000000 9.31322575e-10 0.143194 0.062245
0.50 -0.000000 1.86264515e-09 0.285808 0.073414
0.75 -0.000000 1.86264515e-09 0.428514 0.084606
1.00 +0.000000 1.86264515e-09 0.571781 0.097416

Maximum correlation: 1.000000 → 0.507235; L/R balance −0.012432 dB. Stereo fixture (R is a one-second cyclic shift of L): original Side is retained, with total Side +0.347460/+1.264682 dB at .5/1; mono error ≤3.7252903e-09. Peak growth is unattenuated by design; the linked limiter is unchanged. Broadband-noise balance is not an all-signal guarantee. Character, peak growth and whether the existing nominal 300 Hz HP should change remain maintainer decisions.

For dry plus a delay of D samples, the mono transfer is (1+exp(−jωD))/2 = exp(−jωD/2) cos(ωD/2): magnitude |cos(πfD/Fs)|, nulls f=(2k+1)Fs/(2D). For dry plus a unit-magnitude allpass exp(jφ), it is |cos(φ/2)|. Flat magnitude of an individual allpass therefore does not prove unchanged mono fold-down. Here (L+S+R−S)/2=(L+R)/2 independently of the Side filter; this identity, not a decorrelator's magnitude alone, is the invariant tested below limiting.

Online source material reviewed

  • Raylib 5.5 audio implementation: ReadAudioBufferFramesInMixingFormat() and OnSendAudioDataToDevice() establish the callback format/rate and its position before mixing.
  • Robert Bristow-Johnson's Audio EQ Cookbook, maintained by WebAudio: normalized low-shelf biquad equations; the +10 dB prototype shelf uses S = 1, and the parallel mix scales its added component toward the requested +20 dB endpoint.
  • JUCE compressor reference: reference for the conventional envelope/threshold/ratio gain law. This implementation uses a shared stereo peak detector rather than separate channel gains.
  • JUCE chorus implementation and parameter handling: reference for modulating interpolated short delays, rate/depth controls and wet mixing. Our two-voice feed-forward routing, antisymmetric high-passed Side return are local choices, not copied JUCE code.
  • JUCE reverb reference: reference for the classic eight-comb/four-diffuser topology, damping/feedback and sample-rate-scaled timing. Our tunings, room-size delay scaling and true all-pass equations are independently implemented. No JUCE dependency was added.

These sources were consulted for behavior/math; no JUCE library or JUCE source was added to the game.

Routing references opened for this change

  • Dattorro, JAES 45(9), 1997, §1.3.7: “we use a quadrature oscillator to decrease the correlation.” This supports the existing sine/cosine pair, not the local depth/rate/taps or a guarantee that arbitrary voice signals are uncorrelated. His Table 1 uses 29761 Hz and does not derive this reverb's timings.
  • Kermit-Canfield and Abel, DAFx16, §2 and §4.4: delay/comb methods “impart strong coloration”; their allpass cascade maintains individual-signal magnitude. This patch does not implement that cascade, and their mono-presentation comparison is not proof of original-signal stereo fold-down.
  • Das, DAFx24, §1: “low frequencies are spread less than mid and high frequencies to maintain a strong phantom centre.” Reusing the existing HP follows that rationale; its nominal 300 Hz cutoff is retained, not derived by this paper. The paper's Eq. (1)/(2) uses orthonormal 1/√2 M/S; our half-sum/half-difference convention uses L=M+S, R=M−S instead.