diff --git a/.gitignore b/.gitignore index 355114a..855b708 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ *.gcno compile_commands.json .cache/ +.atl/ +tools/loudness_probe/build/ diff --git a/CMakeLists.txt b/CMakeLists.txt index e746737..b77cfc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,12 @@ cmake_minimum_required(VERSION 3.16) -project(pixelroot32-apu VERSION 1.0.0 LANGUAGES C CXX) +project(pixelroot32-apu VERSION 2.0.0 LANGUAGES C CXX) + +# The test files have pre-existing narrowing conversions in brace-init +# (committed before M7). Suppress this warning so the test build doesn't +# fail on those lines. +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + add_compile_options(-Wno-narrowing) +endif() # --------------------------------------------------------------------------- # Library target @@ -64,4 +71,16 @@ if(PR32_APU_BUILD_TESTS) target_link_libraries(test_q15_smoke PRIVATE pr32_apu_unity) add_test(NAME test_q15_smoke COMMAND test_q15_smoke WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/test) + + # Consumer link surface. Every suite above recompiles src/ApuCore.cpp with + # UNIT_TEST=1, so a definition left inside a `#if defined(UNIT_TEST)` block + # still resolves for them — and the shipped library silently loses the + # symbol. This target is the only one that links `pixelroot32-apu` the way + # a consumer does, so it MUST NOT define UNIT_TEST and MUST NOT compile the + # source itself. + add_executable(test_library_link test/test_library_link.cpp) + target_include_directories(test_library_link PRIVATE test test/support) + target_link_libraries(test_library_link PRIVATE pixelroot32-apu pr32_apu_unity) + add_test(NAME test_library_link COMMAND test_library_link + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/test) endif() diff --git a/docs/ABI.md b/docs/ABI.md index e2eef93..26ca00a 100644 --- a/docs/ABI.md +++ b/docs/ABI.md @@ -51,7 +51,9 @@ Consumers with dynamic presets must guarantee address stability (the Tool Suite Float chain: `Σ(voice · MIXER_SCALE=0.4) · masterVolume → soft-clip 1/(1+|x|·0.5) → HPF R=0.995 → ±32767 → bitcrush`. The Q15 path replicates the same chain with the pre-adjusted LUT and master volume applied **before** the compressor (order parity since v1.0.0). -**Known and deliberate divergence**: the Q15 path applies a fixed −6 dB pad (`>>1`, Q15→Q14) with no float equivalent, inherited from the engine; preserved to avoid altering the volume of FPU-less targets. Pending unification in a major version. +**Loudness parity** (since 2.0.0): both paths render at the same level. The Q15 accumulator carries the **raw** channel sum and `MIXER_SCALE` is applied exactly once downstream — by `audio_mixer_lut`, whose curve is defined over `x = sum · 1.6 / 131072` and therefore bakes the 0.4 in, or explicitly as `(sum · 13107) >> 15` for the computed soft-clip modes. Do **not** pre-scale channels before summation: that applies 0.4 twice and puts the compressor knee in the wrong place. + +Previously the Q15 path was ~0.225× the float level — the double `MIXER_SCALE` plus a fixed −6 dB pad (`>>1`, Q15→Q14) with no float equivalent. Both are gone; consumers on FPU-less targets should re-check their master volume. See the CHANGELOG entry for Hito 5 M12. ## 7. Canonical Percussion Presets diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 48491e7..80de572 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,90 @@ Format: [semver](https://semver.org/). Changes that alter rendered PCM or struct ABI require a major or minor bump with an explicit note. +## [Unreleased] + +## [2.0.0] - 2026-08-08 + +NES-accurate APU emulation (Hitos 0-5), a configurable output stage, and the +unification of the two render paths. + +### Breaking changes + +Three, and only the first is audible to existing consumers on FPU-capable +targets: + +1. **TRIANGLE is quantised to 4-bit NES levels by default** (Hito 1 M1). Opt out + per voice with `voice.triangleQuantize4Bit = false`. The golden PCM was + regenerated for this. +2. **The integer/Q15 render path renders ~4.4x louder** (Hito 5 M12), matching + the float path. Affects FPU-less targets only — ESP32-C3 and any build with + `-DPR32_APU_HAS_FPU=0`. Desktop, ESP32 and ESP32-S3 are unchanged. Consumers + on those targets should re-check their master volume. +3. **The NOISE LFSR seeds to `0x7FFF`** instead of `0x4000` (Hito 1 M9), which + is the NES-equivalent state after a `$400F` write. Changes the first samples + of every NOISE event. + +Everything else added in this release is opt-in and default-off, so a consumer +that upgrades and touches nothing keeps its previous behaviour apart from the +three items above. + +`InstrumentPreset` gained fields but its first 17 positions still mean exactly +what they meant in 1.0.1, so positional aggregate initializers keep working +untouched — see the `noiseShortMode` entry under Fixed for why that is load +bearing. + +### Added +- **API**: NES APU frame counter (Hito 2 M3). Adds the `pixelroot32::audio::NesFrameCounter` struct (mode, currentStep, APU-cycle accumulator, IRQ flag, inhibit flag, reset-pending) and the `nes_apu` namespace in `ApuConfig.h` with the canonical 4-step / 5-step cycle tables (`kMode0ApuCyclesPerStep`, `kMode1ApuCyclesPerStep`, `kNesApuHzNtsc`). New public methods on `ApuCore`: `setNesFrameCounterMode(int)`, `setNesFrameCounterInhibit(bool)`, `getNesFrameCounterIrq()`. Default OFF — the counter does not tick and the canonical track PCM is unchanged until the consumer calls `setNesFrameCounterMode(0 or 1)`. Four private stub methods (`tickAllNesEnvelopes`, `tickAllNesLinearCounters`, `tickAllNesLengthCounters`, `tickAllNesSweepUnits`) plus a private per-sample `tickNesFrameCounter(apuCyclesPerSample)` wired into both the float and Q15 hot paths. Future Hitos (M4 length counter, M5 linear counter, M6 sweep, M7 envelope) fill in the stubs. +- **Tests (UNIT_TEST)**: 8 new Hito 2 M3 tests in `test_apu_core.cpp` covering initial state, mode-set reset, quarter/half clock rates (240/120 Hz mode 0), mode-1 no-IRQ, IRQ rate (60 Hz), IRQ inhibit, and APU-cycle timing at 22050 Hz. 7 new test-only accessors expose the internal counters under `#if defined(UNIT_TEST)`. +- **API**: NES APU length counter (Hito 2 M4). Adds the `pixelroot32::audio::NesLengthCounter` struct (enabled, halt, index, counter) — appended as the LAST member of `AudioChannel` to keep aggregate initializers in test literals positionally stable. Adds `nes_apu::kNesLengthLut[32]` (the canonical nesdev wiki table) in `ApuConfig.h`. New public methods on `ApuCore`: `setVoiceNesLength(int slot, uint8_t lengthIndex, bool halt)` (mirrors the `$4003/$4007/$400B/$400F` length load, counter is initialised to `kNesLengthLut[index]` and silenced on reaching 0) and `setVoiceNesChannelEnabled(int slot, bool enabled)` (mirrors `$4015`: clear forces counter to 0 and silences the voice; set has no immediate effect). The existing M3 `tickAllNesLengthCounters()` stub is filled with the per-half-clock decrement + silence logic; SINE/SAW voices keep `nesLengthCounter.enabled = false` permanently and are skipped. Default OFF — the canonical track PCM remains byte-for-byte identical. `remainingSamples` is intentionally NOT touched (M4 is orthogonal to the pre-Hito-2 duration mechanism). +- **Tests (UNIT_TEST)**: 8 new Hito 2 M4 tests in `test_apu_core.cpp` covering initial state, load, decrement on half-clock, silence at zero, halt prevents decrement, LUT values (compile-time `static_assert` + runtime check), channel-enable-off forces silence, and SINE/SAW voices are not affected. 3 new test-only accessors (`getVoiceNesLengthCounterForTesting`, `getVoiceNesLengthIndexForTesting`, `getVoiceNesLengthHaltForTesting`) gated on `#if defined(UNIT_TEST)`. +- **API**: NES APU linear counter (Hito 2 M5). Adds the `pixelroot32::audio::NesLinearCounter` struct (linearEnabled, reloadFlag, reloadValue, counter, lengthCounter pointer) — appended as the LAST member of `AudioChannel` to keep aggregate initializers in test literals positionally stable. The `lengthCounter` pointer is bound in `AudioChannel::reset()` to share the halt/control flag with `NesLengthCounter` (no duplication). Fills the existing M3 `tickAllNesLinearCounters()` stub: at quarter-frame rate (240 Hz NTSC mode 0), if `reloadFlag` is set the counter is loaded from `reloadValue`, else if `counter > 0` it is decremented; if the shared `halt` flag is clear, `reloadFlag` is then cleared. The TRIANGLE channel is gated per-sample in both the float and Q15 hot paths: output is 0 and the phase does NOT advance when the AND of (length counter open) and (linear counter open) is closed; either sub-unit, when its `enabled` flag is false, is treated as always-open. Surgical fix to the M4 `tickAllNesLengthCounters()`: TRIANGLE voices are no longer disabled when their length counter hits 0 — the M5 per-sample gate handles the silence path, and the voice stays enabled so it can be re-armed by a fresh $400B write. New public methods on `ApuCore`: `setVoiceNesLinearCounter(int slot, uint8_t reloadValue, bool reloadFlag)` (mirrors the $4008 write side effect, masks the high bit, stores regardless of `linearEnabled`) and `setVoiceNesLinearEnabled(int slot, bool enabled)` (mirrors the $4008 bit 7 enable). Default OFF — `nesLinearCounter.linearEnabled == false` on construction, so the counter does not tick and the canonical track PCM is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 10 new Hito 2 M5 tests in `test_apu_core.cpp` covering initial state (all voices default to OFF), load on quarter-clock, decrement at quarter-clock rate, hold with control set, reloadFlag cleared without control, TRIANGLE gate open by default (safety net), TRIANGLE gate closes when length counter hits 0 (verifies the M5 surgical fix keeps the voice enabled), TRIANGLE gate closes when linear counter is 0, TRIANGLE phase does NOT advance when the gate is closed, and SINE voices are unaffected by the linear counter dispatch. 4 new test-only accessors (`getVoiceNesLinearCounterForTesting`, `getVoiceNesLinearReloadValueForTesting`, `getVoiceNesLinearReloadFlagForTesting`, `getVoiceNesLinearEnabledForTesting`) gated on `#if defined(UNIT_TEST)`, plus a new `getVoicePhaseForTesting` for the phase-freeze test. +- **API**: NES APU sweep unit (Hito 2 M6). Adds the `pixelroot32::audio::NesSweepUnit` struct (sweepEnabled E bit, period P, negate N bit, shift SSS, reloadFlag, dividerCounter, isPulse2, 11-bit timer, continuously-evaluated `muted` flag) — appended as the LAST member of `AudioChannel` to keep aggregate initializers in test literals positionally stable. Adds `nes_apu::nesTimerToHz(uint16_t)` and `nes_apu::hzToNesTimer(float)` helpers in `ApuConfig.h` (PULSE NTSC formula `f = 111860.8 / (timer + 1) Hz`). New public methods on `ApuCore`: `setVoiceNesSweep(int slot, bool enabled, uint8_t period, bool negate, uint8_t shift, bool isPulse2)` (mirrors the `$4001`/`$4005` write side effect: clamps P/SSS to 3 bits, sets `reloadFlag = true`, immediately recomputes `muted` so the per-sample gate sees the correct value before the next half-clock) and `setVoiceNesTimer(int slot, uint16_t timer11)` (mirrors the `$4002`/`$4006`/low-3-bits-of-`$4003`/`$4007` timer load: clamps to 11 bits, re-syncs the float / Q32 phase increments so the oscillator frequency matches `111860.8 / (timer + 1) Hz` immediately, recomputes `muted` immediately). The existing M3 `tickAllNesSweepUnits()` stub is filled with the canonical NES half-clock algorithm: per PULSE voice, compute the target period (PULSE 1 uses ones' complement `target = period - change - 1`, PULSE 2 uses two's complement `target = period - change`, add mode is `target = period + change`), evaluate muting CONTINUOUSLY (the "NES bug": `muted = (period < 8) || (target > 0x7FF)`, checked on the raw target BEFORE clamping), update `timer` from the clamped target on the `dividerCounter == 0` boundary when E=1, SSS>0, and `!muted`, then reload the divider to P+1 (with `reloadFlag` consumed). The PULSE output gate is added to both the float and Q15 hot paths: when `nesSweepUnit.muted` is set, the per-sample output is forced to 0 (the phase still advances, matching the NES bug semantics). Non-PULSE voices are skipped by the half-clock dispatch but their `nesSweepUnit` is still readable for introspection. Default OFF — `sweepEnabled = false` and `timer = 0` on construction, the canonical track never enables NES mode, so the rendered PCM is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 10 new Hito 2 M6 tests in `test_apu_core.cpp` covering initial state (all 8 voices default to OFF), PULSE 1 setup + target calculation (timer 256 → 384 after 1 half-clock), PULSE 1 ones'-complement negate (timer 20 → 9, the `target = period - change - 1` path), PULSE 2 two's-complement negate (timer 20 → 10, the `target = period - change` path — exactly 1 higher than PULSE 1), target-overflow muting (timer 1500 + shift 1 → target 2250 > 0x7FF, output silent in the HPF-decayed region), current-period-too-low muting (timer 4 < 8 → muted immediately, output silent), divider reload sequence (P=2: tick 1 → 3, tick 2 → 2, tick 3 → 1, exercising the canonical "reload without decrement on the boundary tick" behaviour), TRIANGLE/NOISE immunity (half-clock dispatch skips non-PULSE voices), reloadFlag cleared after one half-clock (verified by observing the divider decrement on the next tick instead of reloading), and THE NES BUG: muting persists when E=0 and SSS=0 (timer 4 with everything off still gates the output). 6 new test-only accessors (`getVoiceNesSweepTimerForTesting`, `getVoiceNesSweepDividerForTesting`, `getVoiceNesSweepEnabledForTesting`, `getVoiceNesSweepNegateForTesting`, `getVoiceNesSweepShiftForTesting`, `getVoiceNesSweepMutedForTesting`) gated on `#if defined(UNIT_TEST)`. +- **API**: NES APU envelope unit (Hito 2 M7). Adds the `pixelroot32::audio::NesEnvelope` struct (envelopeEnabled, startFlag, constVolume, volume, dividerCounter, decayLevel, output, lengthCounter pointer) — appended as the LAST member of `AudioChannel` to keep aggregate initializers in test literals positionally stable. The `lengthCounter` pointer is bound in `AudioChannel::reset()` to share the loop/halt flag with `NesLengthCounter` (same pattern as M5's linear counter, no duplication). Fills the existing M3 `tickAllNesEnvelopes()` stub: at quarter-frame rate (240 Hz NTSC mode 0), the canonical nesdev wiki algorithm runs (`if startFlag → decayLevel=15, divider=volume; else if divider==0 → divider=volume, if decayLevel>0 then --decayLevel, else if loop then decayLevel=15 else silence; else --divider`; output = `constVolume ? volume : decayLevel`). PULSE and NOISE voices opt in via `setVoiceNesEnvelopeEnabled`; the dispatch loop skips other types. When opted in, the envelope's `output` (0..15) REPLACES the ADSR `envelope.currentLevel` as the per-sample volume source (float path: `output / 15.0`; Q15 path: `output * 2184 = output * 32768/15`). Surgical fix to the M4 `setVoiceNesLength()`: after loading the length counter (and only if the NES channel is enabled), `nesEnvelope.startFlag = true` — mirrors the NES `$4003/$4007/$400F` length-load side effect. New public methods on `ApuCore`: `setVoiceNesEnvelope(int slot, bool loop, bool constVolume, uint8_t volume)` (clamps VVVV to 4 bits, mirrors `$4000/$4004/$400C`, does NOT set the start flag) and `setVoiceNesEnvelopeEnabled(int slot, bool enabled)` (mirrors the opt-in bit). Default OFF — `envelopeEnabled == false` on construction, so the quarter-clock dispatch is a no-op and the canonical track PCM remains byte-for-byte identical. `remainingSamples` is intentionally NOT touched (M7 is orthogonal to the pre-Hito-2 duration mechanism). +- **Tests (UNIT_TEST)**: 10 new Hito 2 M7 tests in `test_apu_core.cpp` covering initial state (all 8 voices default to OFF, all NesEnvelope fields zero, lengthCounter pointer bound), setup + decay over quarter-clocks (start flag → decayLevel=15, divider=8, decrements 8→0, then wrap-and-decrement), loop (decay wraps to 15 instead of silencing, voice stays enabled), constant volume (C=1, output=VVVV regardless of decayLevel), silence at zero (V=0, decayLevel decrements every Q-clock, voice silenced when it hits 0), start flag cleared after first Q-clock (verifies the M4 surgical fix), TRIANGLE/SINE/SAW immunity (dispatch skips non-PULSE/NOISE), volume scaling (V=15 → ~1.0, V=7 → ~7/15 on the int16 output), coexistence with M4 length counter (length counter wins the race to silence), and shared loop flag (length counter `halt` controls the envelope's loop, even when `setVoiceNesEnvelope` was called with loop=false). 6 new test-only accessors (`getVoiceNesEnvelopeOutputForTesting`, `getVoiceNesEnvelopeDecayLevelForTesting`, `getVoiceNesEnvelopeDividerForTesting`, `getVoiceNesEnvelopeStartFlagForTesting`, `getVoiceNesEnvelopeEnabledForTesting`, `getVoiceNesEnvelopeConstVolumeForTesting`) gated on `#if defined(UNIT_TEST)`. + +- **API**: `VoiceNesOptions` + `ApuCore::setVoiceNesOptions(int slot, const VoiceNesOptions&)` / `getVoiceNesOptions(int slot)` and `AudioCommandType::SET_NES_OPTIONS` (Hito 4 M14). Bundles every per-voice NES opt-in — `lengthCounterEnabled` (M4), `linearCounterEnabled` (M5), `envelopeEnabled` (M7), `sweepUnitEnabled` (M6), `pulseDutyIndex` (M2) and `noiseLutIndex` (M8) — so a caller can put a voice into or out of NES mode in one atomic write instead of five calls that could be observed half-applied. All defaults are off, so a default-constructed instance disarms a voice completely. Two entry points with one semantic, the same shape as M13: the setter mutates the voice pool directly and is audio-thread only, while `SET_NES_OPTIONS` carries the struct through the queue and applies it via that same setter, which is what makes the configuration reachable from the game thread at all. The setter delegates to the individual setters rather than writing fields itself, so their validation and side effects apply unchanged — invalid selector indices land on the documented 255 sentinels, and selecting a noise LUT entry retunes a live NOISE voice immediately. `getVoiceNesOptions` reads the live fields, so it also reflects changes made through the individual setters or by M17's `initNesSafe`. Rendered PCM is unchanged and the golden PCM fixture is byte-for-byte identical. +- **Note**: the struct deliberately omits `noiseLfsrShort`, which the original M14 sketch listed. That flag is owned per NOTE by `InstrumentPreset` and rewritten by `initVoiceFromEvent` on every trigger, unlike the per-SLOT modes in the struct; including it would give one field two owners and the preset would silently win on the next note. The sketch also predated M6's `sweepUnitEnabled` (which it lacked) and the Hito 0 rename of `noiseShortMode` to `noiseLfsrShort`. +- **Tests (UNIT_TEST)**: 9 new Hito 4 M14 tests in `test_apu_core.cpp` covering defaults, round-trip, the struct writing the real sub-unit fields rather than a shadow copy, full disarm, invalid indices falling back to the sentinels, the queued command landing on identical state to the direct call, an out-of-range slot being ignored without touching any voice, the noise LUT retuning immediately, and the options reflecting M17's boundary (sub-units cleared by `initNesSafe`, waveform mode selectors preserved). +- **API**: configurable master soft-clip (Hito 4 M11). Adds `SoftClipMode { None, Rational, Tanh, HardClip }` in `AudioTypes.h` and `ApuCore::setSoftClipMode(SoftClipMode)`. The curve shapes the summed mix BEFORE the DC blocker, so the filter sees its output — that ordering is what makes `HardClip` model the NES signal chain, where the DAC saturates and the analog high-pass then filters the already-squared-off signal. `Rational` (`S / (1 + |S| * MIXER_K)`) is the default and reproduces the historical behaviour bit-for-bit: on the integer path it specifically keeps using the pre-fitted `audio_mixer_lut` rather than recomputing the same curve, so the canonical golden PCM does not move. The other three modes are computed on both paths; the integer versions feed the curves `x = sum * 0.4` to match the input convention baked into the LUT, with the tanh approximation evaluated in int64 because `x^2` overflows int32 once several voices sum at full scale. `reset()` restores the default, as it already does for the master volume, the bitcrush depth and the HPF corner. Unknown enum values leave the current curve untouched. Rendered PCM is unchanged for existing consumers and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 8 new Hito 4 M11 tests in `test_apu_core.cpp` plus 4 in `test_q15_smoke.cpp`. Two measurement regimes, because they answer different questions: below unity (one voice, HPF bypassed) no clamp engages, so each curve's level is a direct read-out of its formula and pins both the coefficients and the fixed-point scale — 13107 / 10922 / 12519 for None / Rational / Tanh at S = 0.4, with HardClip a no-op there; above unity (four loud voices, asymmetric duty, HPF running) the final int16 clamp flattens every curve to the same peak, so the tests compare mean magnitude instead and assert the ordering None > Rational > Tanh > HardClip. Note that ordering: `HardClip` caps the mix at 1.0 while the rational curve asymptotes toward `1/MIXER_K = 2.0`, which makes HardClip the *quietest* shaped mode on an overdriven mix, not the loudest. +- **API**: configurable master HPF (Hito 4 M10). Adds `ApuCore::setHpfCutoffHz(float cutoffHz)`. The output stage runs a single-pole DC blocker `y[n] = x[n] - x[n-1] + R*y[n-1]`; `R` used to be the hardcoded literal `0.995f` (and `32604` on the Q15 path), which pins the COEFFICIENT rather than the corner frequency — that is ~35.2 Hz at 44100 Hz but ~17.6 Hz at 22050 Hz, so the filter silently changed character with the sample rate. The cutoff is now stored in Hz and `R = exp(-2*pi*fc/fs)` is derived from it and the current rate, recomputed by `init()` when the rate changes and clamped strictly inside (0, 1) so the recursion cannot go unstable. `cutoffHz <= 0` bypasses the filter entirely, with the filter state kept coherent so re-enabling does not step from a stale value. Reference points: ~35 Hz is this library's historical corner, ~90 Hz approximates the NES's analog output HPF. Until the setter is called the APU keeps the historical coefficient bit-for-bit — deliberately NOT a computed 35 Hz, which would differ in the last bits and move the canonical golden PCM. `reset()` restores that legacy default, the same way it already restores the master volume and bitcrush depth. Applied to both the float and Q15 hot paths. Rendered PCM is unchanged for existing consumers and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 9 new Hito 4 M10 tests in `test_apu_core.cpp` plus 3 in `test_q15_smoke.cpp`. Coverage: the untouched default holding the exact legacy coefficient, coefficient derivation at 90/240 Hz, the same corner surviving a sample-rate change (with the legacy coefficient shown NOT to, as contrast), `init()` recomputing on a rate change, bypass on zero/negative cutoff, `reset()` restoring the legacy default, extreme cutoffs staying stable, and two behavioural tests measuring DC directly on a 12.5%-duty pulse — bypass keeps the offset (~5900 at 44100) while every active corner removes it and a higher corner settles it faster (~1970 / ~760 / ~410 at 35 / 90 / 240 Hz). The Q15 path gets its own DC test because `test_apu_core` compiles with the FPU path and would never exercise the integer branch. +- **API**: NES NOISE period LUT (Hito 3 M8). Adds `nes_apu::kNesNoisePeriodLutNtsc[16]` (the canonical `$400E` NTSC table, in CPU cycles) and `nes_apu::nesNoisePeriodToSamples(uint8_t index, int sampleRate)` in `ApuConfig.h`, plus `AudioChannel::nesNoiseLutIndex` (default 255) and `ApuCore::setVoiceNesNoiseLutMode(int slot, uint8_t lutIndex)`. Index 0..15 selects a LUT entry as the LFSR shift period instead of deriving it from the note frequency; 255 keeps the legacy behaviour and 16..254 are stored as 255. The table is kept in CPU cycles and converted at the current sample rate (`samples = cycles * sampleRate / 1789773`, floored at 1 sample), so the same index yields the same timbre at 22050, 44100 and 48000 Hz rather than being baked for one rate. The first few entries fall below one output sample at every supported rate and collapse onto the 1-sample floor — an inherent limit of a sample-based LFSR against the NES's ~447 kHz maximum noise clock, documented at the helper. Mirrors M2's `pulseDutyIndex` in both sentinel scheme and lifetime: a per-slot mode that survives new notes and is cleared only by `Voice::reset()`, applied on top of the per-note `noisePeriod`/frequency paths in `initVoiceFromEvent` and re-applied immediately by the setter so a live change is audible without waiting for the next note. Deliberately NOT reset by M17's `initNesSafe`, which resets the NES sub-units (length, envelope, sweep, linear counters) and not the waveform mode selectors — same treatment `pulseDutyIndex` already gets. Default OFF, so the canonical golden PCM is byte-for-byte identical. +- **Docs**: corrected the NOISE LUT in `docs/nes-apu-comparison.md` §M8. It listed `..., 508, 1014, 2034, 4068, 8136`, which drops 762, mistypes 1016 as 1014 and shifts the tail up one entry. Verified against https://www.nesdev.org/wiki/APU_Noise; the canonical NTSC table is `4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068`. +- **Tests (UNIT_TEST)**: 9 new Hito 3 M8 tests in `test_apu_core.cpp` — the LUT values pinned against the wiki table plus the sample conversion at two rates and its clamping, default-off keeping the legacy period, the setter retuning immediately, the mode surviving a new note, the sentinel restoring legacy behaviour, invalid indices falling back to off, non-NOISE voices left playing untouched, the mode surviving a `nesAccurate` note, and the LFSR clock landing on the same Hz at 22050 and 44100. New test-only accessor `getVoiceNesNoiseLutIndexForTesting`. +- **API**: `InstrumentPreset::nesAccurate` (Hito 2 M17, default false, appended as the LAST member so the shipped `INSTR_*` presets and the positional test literals stay valid). When true, `initVoiceFromEvent` runs a new internal `initNesSafe(Voice&, WaveType)` helper that resets the voice's NES sub-unit fields to the canonical silent state from the nesdev `APU_basics` `@regs` array — PULSE `$4000=$30 $4001=$08 $4002=$00 $4003=$00`, TRIANGLE `$4008=$80 $400A=$00 $400B=$00`, NOISE `$400C=$30 $400E=$00 $400F=$00`. Decoding the shared `$30` (DDLC VVVV) gives L=1, C=1, VVVV=0, so the length counter is halted and the envelope is constant-volume zero: silent, and nothing counts down on its own. `$08` (EPPP NSSS) gives E=0, P=0, N=1, SSS=0 and is applied to PULSE only (the other channels have no sweep unit). The envelope start flag is raised as the `$4003`/`$400B`/`$400F` side effect, while the linear counter reload flag is explicitly cleared, since `$400B` carries a zero reload value and a sticky flag would reload every quarter-clock instead of once. The helper is a clean slate, NOT an enable: `nesLengthCounter.enabled`, `nesEnvelope.envelopeEnabled`, `nesSweepUnit.unitEnabled` and `nesLinearCounter.linearEnabled` are all cleared, so callers arm what they need afterwards with the `setVoiceNes*` setters or `TRIGGER_NES_LENGTH`. SINE and SAW have no NES counterpart and are left untouched. The call sits at the very end of `initVoiceFromEvent` so no earlier branch can leave stale NES state behind. Default OFF, so the rendered PCM is unchanged for every existing preset and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 7 new Hito 2 M17 tests in `test_apu_core.cpp` — canonical state for PULSE, TRIANGLE and NOISE; a dirty voice returning to the canonical values on retrigger; the default path leaving the NES fields alone; SINE/SAW inert; and the safe state being genuinely silent once the envelope is armed on top of it, silenced by constant-volume-zero rather than by the decay path disabling the voice. New test-only accessor `getVoiceNesEnvelopeVolumeForTesting`. +- **API**: `AudioCommandType::TRIGGER_NES_LENGTH` (Hito 2 M13) — the queued counterpart of `setVoiceNesLength`, so a consumer can fire a NES-length-gated note from the game thread instead of computing a sample budget. Reads `AudioCommand::channelIndex` (voice slot), plus two new plain members `AudioCommand::nesLengthIndex` (LUT index, clamped 0–31) and `AudioCommand::nesLengthHalt`. Out-of-range slots are ignored. Unlike the raw setter, the command arms the voice's NES channel first (`setVoiceNesChannelEnabled(slot, true)`) before the length load: `setVoiceNesLength` silently drops writes to a disabled NES channel, and the enable setter is a direct voice-pool mutation that is not safe to call off the audio thread, so without this the queued path could never fire. This deliberately merges the `$4015` enable with the `$4003`-style length load — `TRIGGER_NES_LENGTH` is an app-level trigger, not a register write. +- **Behavior**: `setVoiceNesLength` now also raises the M5 linear counter reload flag (`nesLinearCounter.reloadFlag = true`), completing the documented side effects of a `$4003`/`$4007`/`$400B`/`$400F` write. Previously only the M7 envelope start flag was raised, so the `$400B` TRIANGLE behavior (length load reloads the linear counter on the next quarter-frame clock) was never wired up — M4 deferred it to M5, and M5 did not pick it up. Like the envelope start flag it is set unconditionally; the dispatchers gate on voice type and on their own opt-in flag, so a voice that did not opt into the linear counter never observes it. No PCM change (the frame counter is OFF by default) and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 8 new Hito 2 M13 tests in `test_apu_core.cpp` — length load from the LUT via the queue, the command arming the NES channel on its own, both sub-unit flags raised by the command, the same two flags raised by the direct setter (one behavior, two entry points), the linear counter actually reloading from `reloadValue` on the next quarter-clock (proves the flag drives the sub-unit rather than just being set), index clamping to 31, out-of-range slot ignored without touching any voice, and an end-to-end trigger that lets the length counter silence the voice with no sample budget computed by the caller. New test-only accessor `getVoiceNesChannelEnabledForTesting`. + +### Fixed +- **Build**: the entire NES public API was missing from the shipped library. A `#if defined(UNIT_TEST)` block that legitimately wraps the `*ForTesting` accessors had grown to enclose 17 production methods as well — `setNesFrameCounterMode`/`Inhibit`, `getNesFrameCounterIrq`, all four `tickAllNes*` dispatchers, every `setVoiceNes*` setter and the private per-sample `tickNesFrameCounter`. Their call sites sit outside the guard, so `libpixelroot32-apu.a` compiled cleanly and then failed to link with 13 undefined references the moment any consumer used it (reported from PixelRoot32-Tool-Suite, which links `PixelRoot32::APU`). Nothing in this repo caught it: every test target recompiles `src/ApuCore.cpp` with `UNIT_TEST=1`, so the definitions always resolved there. The guard now closes before the NES section and reopens after it, and two test-only counters that production functions were incrementing unguarded (`nesQuarterTickCountForTesting_`, `nesIrqSetCountForTesting_`) got their own guards. No behaviour change under `UNIT_TEST`; rendered PCM unchanged and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: new `test_library_link` target and suite. It is the only one that links `pixelroot32-apu` the way a consumer does instead of recompiling the source, and it is compiled deliberately WITHOUT `UNIT_TEST` (with an `#error` guard so it cannot drift). It calls the whole public surface — transport, output stage, waveform mode selectors, every NES sub-unit setter and `VoiceNesOptions` — asserting almost nothing, because what it proves is that the symbols EXIST in the artifact consumers receive. Add a call here when adding a public method. Mutation-checked: restoring the old guard boundary makes it fail to link. +- **ABI**: `InstrumentPreset::noiseShortMode` (the deprecated alias introduced by the Hito 0 rename) moved to the END of the struct, after `nesAccurate`. It had been inserted immediately after `noiseLfsrShort` at position 15, which pushed `dutySweep`, `pitchSweepEndHz` and `pitchSweepDurationSec` down one slot. Both consumers build presets with POSITIONAL aggregate initializers written against the 1.0.1 field order, so every such literal either failed to compile — a `float` landing on the new `bool` is a narrowing conversion, ill-formed in aggregate initialization — or silently rebound its trailing values. Measured blast radius before this fix: 45 literals across 7 files in PixelRoot32-Game-Engine and 6 sites in PixelRoot32-Tool-Suite, including the two factory functions behind ~65 catalog entries. With the alias last, positions 1..17 mean exactly what they meant in 1.0.1 and all of those compile and map correctly untouched (verified by compiling Engine and Tool-Suite literals verbatim against the header, without `-Wno-narrowing`). The alias is inert on `InstrumentPreset` — only `noiseLfsrShort` drives synthesis (`ApuCore.cpp`) — so moving it changes no behaviour. The shipped `INSTR_SNARE` / `INSTR_HIHAT` now set it explicitly as the trailing member to keep it in sync with `noiseLfsrShort`; the other eight presets let it default. Rendered PCM is unchanged and the golden PCM fixture is byte-for-byte identical. +- **Tests (UNIT_TEST)**: 2 new tests in `test_audio_music_types.cpp`. `test_instrument_preset_positional_layout_matches_published_abi` builds a 17-element positional literal in the exact 1.0.1 field order, with a distinctive value per trailing field so a one-slot shift is unmissable rather than hidden behind a matching default, and asserts each lands on its intended member — this is the consumer contract, and it fails loudly if anything is ever inserted mid-struct again. `test_shipped_presets_keep_deprecated_alias_in_sync` walks all ten `INSTR_*` constants asserting the alias equals `noiseLfsrShort`, plus a direct assertion that `INSTR_SNARE` / `INSTR_HIHAT` are true so an all-false regression cannot satisfy the loop; it exists because the alias no longer sits next to the field it mirrors and is now easy to forget. Three positional literals in `test_apu_core.cpp` (`kSweepPreset` ×2, `kPresetDuty50`) lost the extra element Hito 0 had forced on them — the same patch the consumers were about to be asked to apply. +- **Behavior**: the NES sweep unit (Hito 2 M6) is now opt-in per voice. `tickAllNesSweepUnits()` used to evaluate every PULSE voice regardless of whether it had entered NES mode; since `NesSweepUnit::timer` defaults to 0, the continuous muting rule `period < 8` was always satisfied, so a single call to `setNesFrameCounterMode(0 or 1)` silenced every PULSE voice still driven by the legacy float frequency path. Adds `NesSweepUnit::unitEnabled` (default false) and the public setter `ApuCore::setVoiceNesSweepUnitEnabled(int slot, bool enabled)`, matching the existing per-voice opt-in flags of M4 (`NesLengthCounter::enabled`), M5 (`NesLinearCounter::linearEnabled`) and M7 (`NesEnvelope::envelopeEnabled`). Note this is NOT the `$4001`/`$4005` E bit — that remains the `enabled` argument of `setVoiceNesSweep`. The half-clock dispatch skips unarmed voices, and `recomputeSweepMuted` forces `muted = false` for them, so the invariant "`muted` is only ever true while `unitEnabled` is true" holds and the per-sample hot path still tests `muted` alone (no extra branch). The register-write setters `setVoiceNesSweep` and `setVoiceNesTimer` deliberately do NOT arm the unit, mirroring how `setVoiceNesLength` / `setVoiceNesLinearCounter` / `setVoiceNesEnvelope` leave their own opt-in flags untouched; toggling `setVoiceNesSweepUnitEnabled` re-evaluates `muted` immediately in both directions. Rendered PCM is unchanged for existing consumers (the frame counter is OFF by default, so the defect was never reachable through the canonical track), and the golden PCM fixture is byte-for-byte identical. Consumers that already call `setVoiceNesSweep`/`setVoiceNesTimer` and expect sweep behavior MUST now also call `setVoiceNesSweepUnitEnabled(slot, true)`. +- **Tests (UNIT_TEST)**: 3 new M6 tests in `test_apu_core.cpp` — an unarmed PULSE voice stays audible once the frame counter runs (the regression itself), disarming clears the muting flag, and the register-write setters do not arm the unit. New test-only accessor `getVoiceNesSweepUnitEnabledForTesting` (distinct from `getVoiceNesSweepEnabledForTesting`, which reports the E bit). The 9 pre-existing M6 tests now arm the unit explicitly; three of them (`test_sweep_muting_target_overflow`, `test_sweep_muting_current_period_too_low`, `test_sweep_muting_persists_when_disabled`) were additionally fixed to target the SFX slot the looping PULSE event actually lands on instead of slot 0 — they had been asserting silence on a slot that was never audible, and only passed because the defect muted every PULSE voice at once. + +### Changed +- **BREAKING (Behavior, no-FPU targets only)**: the integer/Q15 render path now produces the same loudness as the float path (Hito 5 M12). Two independent attenuations were stacked on that path and both are gone. First, `MIXER_SCALE = 0.4` was applied twice: once per channel before summation (`sv = (sv * 13107) >> 15`) and again inside `audio_mixer_lut`, whose curve is `32767 * S / (1 + |S| * 0.5)` with `S = sum * 1.6 / 131072` — i.e. the table maps a RAW four-channel sum (`±131072`, `S = ±1.6`, hence `LUT[0] = -29126`) and bakes the 0.4 in itself. Pre-scaling meant the accumulator only ever reached `±52428`, so the outer ~60 % of the table was unreachable and the compressor's knee sat in the wrong place. Second, the documented fixed −6 dB headroom pad (`>>1`, Q15 → Q14) had no float equivalent. Combined, ESP32-C3 and other FPU-less targets rendered at ~0.225× the level of ESP32/ESP32-S3 for identical input — the `ABI.md §6` note only ever accounted for the 0.5 of that. The accumulator now carries the raw channel sum and `MIXER_SCALE` is applied exactly once downstream (by the LUT for `Rational`, explicitly as `(sum * 13107) >> 15` for the computed curves), and the output is no longer padded. **Consumers running on no-FPU targets will hear this path get ~4.4× louder and should re-check their master volume.** Also removes a per-channel truncation to Q15 that was quantizing every voice before summation. The **canonical golden PCM fixture is byte-for-byte identical and was NOT regenerated** — contrary to the milestone sketch, `test_apu_mirror` compiles with `PR32_APU_HAS_FPU=1`, so the fixture is rendered through the float path, which this change does not touch. Nothing changes for desktop, ESP32 or ESP32-S3 builds. +- **Fixed (Behavior, no-FPU targets only)**: the Q15 path now saturates before the final `int16_t` cast, as the float path always did. The `>>1` pad had kept the value below half scale, so the overflow was unreachable; without it an unshaped over-unity mix reaches `S = 1.6` (52428 in Q15) and, with the HPF bypassed (its own clamp skipped), the cast wrapped — a full-scale positive peak came out as a large negative sample. Only reachable via `setSoftClipMode(None)` or `HardClip` combined with `setHpfCutoffHz(0)`; the default `Rational` + active HPF was never affected. +- **Tests (UNIT_TEST)**: 2 new Hito 5 M12 tests in `test_q15_smoke.cpp`, and `test_q15_softclip_computed_modes_track_the_lut` replaced by `test_q15_softclip_curve_shapes_match_the_float_path`, which pins 13107 / 10922 / 12519 for None / Rational / Tanh at `S = MIXER_SCALE` — the exact numbers `test_softclip_curve_shapes_below_unity` already pins on the float path. That identity is the milestone's actual contract, and pinning both binaries to the closed-form value of the mixing chain is the only way to assert it across two separate executables. The second test drives four voices past unity and asserts both rails pin at `+32767` / `-32768`; a wrapped output collapses to ~`±13107` instead. `test_q15_softclip_rational_compresses_below_none` now compares mean magnitude rather than peak, because above unity the final clamp flattens every curve to the same peak — the same reasoning `measureOverdrive` uses on the float side. +- **API**: renamed `InstrumentPreset::noiseShortMode` → `noiseLfsrShort` (and the same rename on `AudioChannel`) to better describe the actual LFSR tap it selects. The old field is kept as a docstring-deprecated alias and is written by `ApuCore::initVoiceFromEvent` whenever the new one is read, so existing consumer code that still touches `noiseShortMode` continues to read the correct value through the normal APU lifecycle. The two fields are independent `bool` storage in C++17 (no automatic sync), so direct writes to one without going through `initVoiceFromEvent` will NOT propagate to the other — use `noiseLfsrShort` in new code. The deprecated field will be removed in a future major version. Behavior and PCM output are unchanged; the golden PCM fixture is byte-for-byte identical. +- **Behavior**: TRIANGLE wave output is now quantized to 4-bit NES levels (32 steps) by default. Opt out per voice with `voice.triangleQuantize4Bit = false`. Golden PCM regenerated. +- **API**: added `AudioChannel::triangleQuantize4Bit` (default true). +- **API**: added `AudioChannel::pulseDutyIndex` (default 255 = continuous); 0..3 selects NES discrete duty patterns. Companion setter `ApuCore::setVoicePulseDutyMode(int slot, uint8_t index)`. +- **API**: added `AudioChannel::triangleOctaveUp` (default false). When true on a TRIANGLE voice, the effective frequency at init is doubled to match NES convention. +- **Behavior**: NOISE LFSR is now seeded to `0x7FFF` (all 1s, NES-equivalent) instead of `0x4000`. Matches NES after a write to `$400F`. + +### Migration +- Consumers using the NES sweep unit must add `setVoiceNesSweepUnitEnabled(slot, true)` alongside their existing `setVoiceNesSweep` / `setVoiceNesTimer` calls; without it the voice is no longer under sweep control and is never muted. Nothing else changes, and consumers that never enabled the NES frame counter are unaffected. +- Consumers relying on smooth triangular waves should set `voice.triangleQuantize4Bit = false` on the affected voices. +- Consumers loading legacy project files with `noiseShortMode` should continue to work (alias retained from Hito 0). + ## v1.0.1 — 2026-08-01 Packaging/metadata release for the first PlatformIO Registry publish. No source, ABI or PCM changes. diff --git a/include/pixelroot32/apu/ApuConfig.h b/include/pixelroot32/apu/ApuConfig.h index bf249b0..91a165b 100644 --- a/include/pixelroot32/apu/ApuConfig.h +++ b/include/pixelroot32/apu/ApuConfig.h @@ -96,4 +96,152 @@ namespace pixelroot32::audio { */ void apuLogf(ApuLogLevel level, const char* fmt, ...); + // ----------------------------------------------------------------------- + // NES APU frame counter timing (Hito 2 M3) + // ----------------------------------------------------------------------- + // + // CPU cycle rate NTSC = 1.789773 MHz. 1 APU cycle = 2 CPU cycles, so the + // frame sequencer ticks at 894886.5 Hz. Tables below are the APU-cycle + // thresholds for each step of the 4-step (mode 0) and 5-step (mode 1) + // sequences; see https://www.nesdev.org/wiki/APU_Frame_Counter. + namespace nes_apu { + /** CPU clock NTSC in Hz. */ + inline constexpr double kNesCpuHzNtsc = 1789773.0; + /** APU cycle rate NTSC = CPU/2 in Hz. */ + inline constexpr double kNesApuHzNtsc = kNesCpuHzNtsc / 2.0; + + /** + * APU cycles threshold for entry to step N (1-indexed) of the + * 4-step (mode 0) sequence. Index 0 is the wrap point at 0. + */ + inline constexpr double kMode0ApuCyclesPerStep[5] = { + 0.0, // 0: wrap + 3728.5, // 1: quarter + 7456.5, // 2: quarter + half + 11185.5, // 3: quarter + 14914.5, // 4: half (+ irq on wrap if not inhibited) + }; + + /** + * APU cycles threshold for entry to step N (1-indexed) of the + * 5-step (mode 1) sequence. Index 0 is the wrap point at 0. + */ + inline constexpr double kMode1ApuCyclesPerStep[6] = { + 0.0, // 0: wrap + 3728.5, // 1: quarter + 7456.5, // 2: quarter + half + 11185.5, // 3: quarter + 14914.5, // 4: half + 18640.5, // 5: quarter + half + }; + + /** + * NES length counter lookup table (32 values). Mirrors the + * canonical table on https://www.nesdev.org/wiki/APU_Length_Counter. + * + * Per the wiki, the values are "the actual values the length + * counter gets loaded with plus one, to allow a model where the + * channel is silenced when the length counter becomes zero." + * We use the model where the counter is initialised to + * `kNesLengthLut[index]` and silenced when it BECOMES 0, i.e. + * after `kNesLengthLut[index]` half-clock decrements. + * + * Hito 2 M4. + */ + inline constexpr uint8_t kNesLengthLut[32] = { + 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, + 12, 16, 24, 18, 48, 20, 96, 22, 192, 24, 72, 26, 16, 28, 32, 30 + }; + + // ----------------------------------------------------------------------- + // NES PULSE timer <-> Hz conversion (Hito 2 M6) + // ----------------------------------------------------------------------- + // + // The NES APU drives the PULSE timer from the CPU clock divided by 16: + // f = CPU_NTSC / 16 / (timer + 1) + // = 1789773 / 16 / (timer + 1) + // = 111860.8125 / (timer + 1) + // + // Source: https://www.nesdev.org/wiki/APU_Pulse (timer = 11-bit, range + // 0..2047). The value 111860.8 is the canonical rounded constant used + // by the APU community; the last 0.0125 Hz per step is below the + // resolution of any consumer oscillator. + // + // These helpers let composers port NES music to PixelRoot32 without + // having to redo the period-to-Hz math by hand. They are pure / no + // side effects, so they can be called from any thread. + + /** Convert an 11-bit NES PULSE timer period to Hz (NTSC). */ + inline float nesTimerToHz(uint16_t timer) { + if (timer > 2047) timer = 2047; + return 111860.8f / static_cast(timer + 1u); + } + + /** + * Convert Hz to an 11-bit NES PULSE timer period (NTSC). Clamped to + * [0, 2047]. Non-positive Hz returns 2047 (highest timer = lowest Hz). + */ + inline uint16_t hzToNesTimer(float hz) { + if (!(hz > 0.0f)) return 2047u; + float t = 111860.8f / hz - 1.0f; + if (t < 0.0f) t = 0.0f; + if (t > 2047.0f) t = 2047.0f; + return static_cast(t); + } + + // ----------------------------------------------------------------------- + // NES NOISE period LUT (Hito 3 M8) + // ----------------------------------------------------------------------- + + /** + * The 16 NTSC noise timer periods, in CPU cycles, indexed by + * `$400E` bits 3-0. Source: + * https://www.nesdev.org/wiki/APU_Noise + * + * "The period determines how many CPU cycles happen between shift + * register clocks. These periods are all even numbers because there + * are 2 CPU cycles in an APU cycle." + * + * NOTE: the internal `docs/nes-apu-comparison.md` sketch lists this + * table as `... 508, 1014, 2034, 4068, 8136`, which is wrong — it + * drops 762, mistypes 1016 as 1014, and shifts the tail up by one + * entry. The values below are the canonical wiki table. + * + * PAL uses a different table; this library models NTSC only, which + * matches `kNesCpuHzNtsc` used everywhere else. + * + * Hito 3 M8. + */ + inline constexpr uint16_t kNesNoisePeriodLutNtsc[16] = { + 4, 8, 16, 32, 64, 96, 128, 160, + 202, 254, 380, 508, 762, 1016, 2034, 4068 + }; + + /** + * Convert a NOISE LUT index to an LFSR shift period expressed in + * output samples at `sampleRate`. + * + * The table is stored in CPU cycles so the same index yields the + * same timbre at any sample rate: + * samples = cycles * sampleRate / kNesCpuHzNtsc + * + * Indices above 15 are clamped to 15. The result is floored at 1 + * sample: the NES clocks noise up to ~447 kHz, so the first few + * entries land below one output sample at any rate we support and + * collapse onto the fastest representable period. That is an + * inherent limit of a sample-based LFSR, not a rounding bug. + * + * Hito 3 M8. + */ + inline uint32_t nesNoisePeriodToSamples(uint8_t index, int sampleRate) { + if (sampleRate <= 0) return 1u; + if (index > 15u) index = 15u; + const double samples = + static_cast(kNesNoisePeriodLutNtsc[index]) + * static_cast(sampleRate) / kNesCpuHzNtsc; + if (samples < 1.0) return 1u; + return static_cast(samples + 0.5); + } + } // namespace nes_apu + } // namespace pixelroot32::audio diff --git a/include/pixelroot32/apu/ApuCore.h b/include/pixelroot32/apu/ApuCore.h index ff3eba6..b17f2aa 100644 --- a/include/pixelroot32/apu/ApuCore.h +++ b/include/pixelroot32/apu/ApuCore.h @@ -34,7 +34,10 @@ namespace pixelroot32::audio { * DC-blocker to remove offset + transient clicks. * * On cores without an FPU (ESP32-C3) the integer-optimised path uses - * `audio_mixer_lut` which is pre-fitted to the same curve. + * `audio_mixer_lut`, which is pre-fitted to the same curve and renders + * at the same level (Hito 5 M12). It applies MIXER_SCALE once, after + * summation rather than per channel, because the table's mapping already + * contains the 0.4 — see AudioMixerLUT.h. */ class ApuCore { public: @@ -160,6 +163,364 @@ namespace pixelroot32::audio { */ void setPostMixMono(void (*fn)(int16_t* mono, int length, void* user), void* user); + /** + * @brief Set the PULSE duty mode for a voice slot. + * + * Hito 1 M2. Opt-in NES-accurate duty (12.5/25/50/75 %) for PULSE voices. + * The default `pulseDutyIndex == 255` keeps the legacy continuous + * `dutyCycle` behaviour, so existing consumers are unaffected. + * + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param nesDutyIndex 0..3 for NES discrete duty, 4..254 treated as + * continuous (defensive), 255 for continuous. + */ + void setVoicePulseDutyMode(int slot, uint8_t nesDutyIndex); + + /** + * @brief Set the NES NOISE period LUT mode for a voice slot. + * + * Hito 3 M8. Opt-in NES-accurate noise colours: index 0..15 selects + * a period from `nes_apu::kNesNoisePeriodLutNtsc` (the canonical + * `$400E` table, in CPU cycles) instead of deriving the LFSR clock + * from the note frequency. The table is converted to a sample + * countdown at the current sample rate, so the same index gives the + * same timbre at 22050, 44100 or 48000 Hz. + * + * The default `nesNoiseLutIndex == 255` keeps the legacy + * frequency-derived behaviour, so existing consumers and the + * canonical golden PCM are unaffected. + * + * Like `setVoicePulseDutyMode`, this is a per-slot mode rather than + * a per-note value: it survives new notes on the same slot and is + * cleared only by `Voice::reset()`. When the slot currently holds a + * NOISE voice the period is retuned immediately, so the change is + * audible without waiting for the next note. On non-NOISE voices the + * index is stored but nothing else is touched. + * + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param lutIndex 0..15 to select a LUT entry; 16..254 are invalid + * and stored as 255; 255 = derive from frequency. + */ + void setVoiceNesNoiseLutMode(int slot, uint8_t lutIndex); + + /** + * @brief Set the master high-pass (DC blocker) corner frequency. + * + * Hito 4 M10. The output stage runs a single-pole high-pass + * `y[n] = x[n] - x[n-1] + R*y[n-1]` with `R = exp(-2*pi*fc/fs)`. + * + * Until this is called the APU uses the historical fixed + * coefficient `R = 0.995`, which pins the COEFFICIENT rather than + * the corner: that is ~35.2 Hz at 44100 Hz but ~17.6 Hz at + * 22050 Hz, so the filter changed character with the sample rate. + * Calling this stores the corner in Hz and derives `R` from the + * current rate instead, so the same value means the same filter + * everywhere. `init()` recomputes it when the rate changes. + * + * The default is left bit-identical on purpose: it keeps the + * canonical golden PCM unchanged for existing consumers. + * + * Reference points: ~35 Hz is this library's historical corner and + * ~90 Hz approximates the NES's analog output HPF. `reset()` + * restores the legacy default, the same way it restores the master + * volume and bitcrush depth. + * + * Not safe to call concurrently with rendering; treat it like the + * other direct configuration setters. + * + * @param cutoffHz Corner in Hz. Values <= 0 bypass the filter + * entirely. Clamped internally so the recursive + * coefficient always stays inside (0, 1). + */ + void setHpfCutoffHz(float cutoffHz); + + /** + * @brief Set the master output shaping curve. + * + * Hito 4 M11. The curve is applied to the summed mix BEFORE the DC + * blocker, so the filter sees its output. That ordering is what + * makes `SoftClipMode::HardClip` model the NES signal chain: the + * DAC saturates, and the analog high-pass then filters the + * already-squared-off signal. + * + * `SoftClipMode::Rational` is the default and reproduces the + * historical behaviour bit-for-bit — on the integer path it keeps + * using the pre-fitted `audio_mixer_lut` rather than recomputing + * the same curve, so the canonical golden PCM does not move. The + * other modes are computed on both paths. + * + * `reset()` restores the default, the same way it restores the + * master volume, the bitcrush depth and the HPF corner. + * + * Not safe to call concurrently with rendering; treat it like the + * other direct configuration setters. + * + * @param mode Curve to apply. Unknown values are ignored. + */ + void setSoftClipMode(SoftClipMode mode); + + /** + * @brief Apply a whole NES option set to a voice slot. + * + * Hito 4 M14. Bundles every per-voice NES opt-in so a caller can + * put a voice into (or out of) NES mode in one atomic write instead + * of five separate calls that could be observed half-applied. The + * dedicated setters remain the right tool for changing one thing. + * + * Delegates to those setters rather than writing the fields itself, + * so their validation and side effects apply unchanged: invalid + * selector indices land on the documented sentinels, and selecting + * a noise LUT entry retunes a live NOISE voice immediately. + * + * Not safe to call concurrently with rendering. Use + * `AudioCommandType::SET_NES_OPTIONS` from the game thread — it + * carries the same struct through the queue and applies it via this + * method, so both entry points produce identical state. + * + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param opts Options to apply. A default-constructed value disarms + * every NES sub-unit on the voice. + */ + void setVoiceNesOptions(int slot, const VoiceNesOptions& opts); + + /** + * @brief Read back a voice's current NES options. + * + * Hito 4 M14. Reflects the live per-voice fields, so it also shows + * changes made through the individual setters or by M17's + * `initNesSafe` (which clears the sub-unit flags but deliberately + * leaves the waveform mode selectors alone). + * + * @param slot Voice index [0, MAX_VOICES); out-of-range returns a + * default-constructed value. + */ + VoiceNesOptions getVoiceNesOptions(int slot) const; + + // -- NES frame counter (Hito 2 M3) ----------------------------- + /** + * @brief Set the NES frame counter mode. + * @param mode 0 = 4-step (240/120/60 Hz, IRQ at 60 Hz), + * 1 = 5-step (~192/~96 Hz, no IRQ). + * + * Mirrors the $4017 write side effects: schedules a timer reset on + * the next tick, and clears the IRQ flag. Values other than 0 or 1 + * are silently ignored. Calling with the current mode is a no-op + * (idempotent). Default is OFF — the counter does not tick and + * the canonical track PCM is unaffected. + */ + void setNesFrameCounterMode(int mode); + + /** + * @brief Inhibit the frame interrupt flag. + * @param inhibit true = clear the IRQ flag and prevent it from being + * set on quarter/half clocks. + * + * Mirrors the I bit ($4017 bit 6). + */ + void setNesFrameCounterInhibit(bool inhibit); + + /** + * @brief Returns the current frame interrupt flag. + * @return true if the IRQ flag is set (only ever true in mode 0). + * + * Mirrors a read of $4015 (but does NOT clear the flag). + */ + bool getNesFrameCounterIrq() const; + + // -- NES length counter (Hito 2 M4) ----------------------------- + /** + * @brief Load the NES length counter for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param lengthIndex 0..31 (NES LUT index). Values > 31 are clamped to 31. + * @param halt Halt flag: true = freeze the counter (do not decrement). + * + * Mirrors the length load side effect of a write to + * $4003/$4007/$400B/$400F. The counter is immediately usable: the + * next half-clock tick from the NES frame counter will decrement + * from `nes_apu::kNesLengthLut[lengthIndex]`. If the voice's NES + * channel enable is clear (`nesLengthCounter.enabled == false`), + * this call is a no-op (writes to a disabled NES channel are + * silently lost). + * + * Two side effects come with the load, matching the hardware: + * the M7 envelope start flag is raised (`$4003`/`$4007`/`$400F`) + * and the M5 linear counter reload flag is raised (`$400B`). + * Both are set unconditionally; their dispatchers gate on voice + * type and on their own opt-in flag, so a voice that did not opt + * into those sub-units never observes them. + * + * Thread safety: this is a direct mutation of the voice pool and + * is NOT safe to call from a thread other than the audio thread. + * Use `AudioCommandType::TRIGGER_NES_LENGTH` for the queued path. + * + * Hito 2 M4; side effects completed in M7 and M13. + */ + void setVoiceNesLength(int slot, uint8_t lengthIndex, bool halt); + + /** + * @brief Set the NES channel enable for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled true = channel is on, false = forced silence. + * + * Mirrors the effect of writing to $4015. When cleared, the + * length counter is forced to 0 and the voice is silenced + * (enabled=false, envelope OFF, currentLevel=0). When set, the + * counter can be loaded via `setVoiceNesLength` but no immediate + * effect on the counter value is taken. + * + * `remainingSamples` is left untouched (M4 is orthogonal to the + * pre-Hito-2 duration mechanism). Hito 2 M4. + */ + void setVoiceNesChannelEnabled(int slot, bool enabled); + + // -- NES linear counter (Hito 2 M5) ------------------------------- + /** + * @brief Load the NES linear counter for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param reloadValue 7-bit reload value (high bits are masked off). + * @param reloadFlag true = load `reloadValue` into `counter` on the + * next quarter-clock; cleared automatically if + * the shared halt/control flag is clear. + * + * Mirrors the linear counter load side effect of a write to + * $4008. The values are always stored; the counter only acts on + * them when `linearEnabled` is true (i.e. the consumer has opted + * in via `setVoiceNesLinearEnabled`). The TRIANGLE voice is the + * only consumer in this library. + * + * Hito 2 M5. + */ + void setVoiceNesLinearCounter(int slot, uint8_t reloadValue, bool reloadFlag); + + /** + * @brief Set the NES linear counter enable for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled true = the linear counter ticks at quarter-frame + * rate and gates the TRIANGLE sequencer per-sample. + * + * Mirrors the linear counter enable bit ($4008 bit 7, "length + * counter / linear counter enable"). When false, the counter is + * bypassed entirely (the gate is treated as always open and the + * counter does not tick). The TRIANGLE voice is the only consumer + * in this library; SINE/SAW/PULSE/NOISE keep it false. + * + * Hito 2 M5. + */ + void setVoiceNesLinearEnabled(int slot, bool enabled); + + // -- NES envelope unit (Hito 2 M7) ---------------------------- + /** + * @brief Configure the NES envelope unit for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param loop L bit (shared with length counter `halt`). When true, + * the decay level wraps from 0 back to 15 instead of + * silencing the channel. The value is stored in the + * envelope struct, but the live read at quarter-clock + * time comes from `nesLengthCounter.halt` (same bit). + * @param constVolume C bit. When true, the output is the + * constant `volume` (VVVV); when false, the + * output is the decremented `decayLevel`. + * @param volume VVVV (4 bits, clamped). Constant volume (C=1) or + * divider reload value (C=0). + * + * Mirrors the $4000 / $4004 / $400C write side effects. Does NOT + * set the start flag (that comes from `setVoiceNesLength`). + * Non-PULSE / non-NOISE voices still have the field written so + * callers can introspect the configuration, but the + * quarter-clock dispatch skips them. + * + * Hito 2 M7. + */ + void setVoiceNesEnvelope(int slot, bool loop, bool constVolume, uint8_t volume); + + /** + * @brief Set the NES envelope enable for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled true = the quarter-frame dispatch ticks the + * envelope and its `output` replaces the ADSR + * level as the per-sample volume source + * (PULSE / NOISE only). + * + * When false, the quarter-frame dispatch is a no-op for this + * voice and the ADSR envelope continues to drive the volume. + * TRIANGLE / SINE / SAW voices keep this false permanently. + * + * Hito 2 M7. + */ + void setVoiceNesEnvelopeEnabled(int slot, bool enabled); + + // -- NES sweep unit (Hito 2 M6) ------------------------------- + /** + * @brief Configure the NES sweep unit for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled E bit. When false, the timer-update branch is + * disabled (but the muting flag is still evaluated — + * the "NES bug"). + * @param period P (3 bits, clamped to 0..7). Divider period in + * half-clocks. P+1 half-clocks elapse between + * timer updates. + * @param negate N bit. When true, the target is computed by + * subtracting `change` from the current timer + * (with the PULSE 1/2 formula difference). + * @param shift SSS (3 bits, clamped to 0..7). Right-shift amount + * for the change calculation. 0 disables the + * timer-update branch. + * @param isPulse2 true = PULSE 2 (two's complement negate: + * `target = period - change`); false = + * PULSE 1 (ones' complement negate: + * `target = period - change - 1`). + * + * Mirrors the $4001 / $4005 write side effect. Sets + * `reloadFlag = true` so the next half-clock reloads the + * divider counter from `period + 1`. Non-PULSE voices are + * silently ignored by the half-clock dispatch (the field is + * still updated so callers can introspect the configuration). + * + * Hito 2 M6. + */ + void setVoiceNesSweep(int slot, bool enabled, uint8_t period, bool negate, + uint8_t shift, bool isPulse2); + + /** + * @brief Opt a voice slot into (or out of) the NES sweep unit. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled true to place the voice under NES sweep control. + * + * This is the per-voice participation gate, NOT the `$4001`/`$4005` + * E bit — that one is the `enabled` argument of + * `setVoiceNesSweep`. Same role as `setVoiceNesChannelEnabled` + * (M4), `setVoiceNesLinearEnabled` (M5) and + * `setVoiceNesEnvelopeEnabled` (M7). + * + * Default OFF. While it is off the half-clock dispatch skips the + * voice entirely and `muted` is held false, so a PULSE voice + * driven by the legacy float frequency path keeps playing when + * the frame counter is switched on. Toggling this re-evaluates + * `muted` immediately in both directions. + * + * Hito 2 M6. + */ + void setVoiceNesSweepUnitEnabled(int slot, bool enabled); + + /** + * @brief Set the 11-bit NES timer period for a voice slot. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param timer11 11-bit NES timer period (clamped to 0..2047). + * + * Mirrors the $4002 / $4006 / low-3-bits-of-$4003/$4007 timer + * load. Updates the per-voice sweep unit's `timer` field, then + * re-syncs the float / Q32 phase increments so the oscillator + * frequency matches `111860.8 / (timer + 1)` Hz immediately + * (the hot path does not need to wait for the next half-clock). + * + * Also recomputes the `muted` flag right away, so the PULSE + * output gate sees the correct value before the next + * half-clock tick. + * + * Hito 2 M6. + */ + void setVoiceNesTimer(int slot, uint16_t timer11); + // -- Profiling API (public for Engine access) ------------- /** @brief Ring buffer size for profile entries. */ static constexpr int PROFILE_RING_SIZE = 64; @@ -247,16 +608,266 @@ namespace pixelroot32::audio { * @return Frequency in Hz if slot is valid, 0 otherwise. */ float getVoiceFrequencyForTesting(int slot) const; + /** + * @brief Test-only: pre-set the M16 triangleOctaveUp flag on a voice slot. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range ignored. + * @param enabled true to double the effective frequency on the next + * initVoiceFromEvent that targets a TRIANGLE voice. + */ + void setVoiceTriangleOctaveUpForTesting(int slot, bool enabled); /** * @brief Test-only: current PULSE duty cycle [0,1]. * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. */ float getVoiceDutyCycleForTesting(int slot) const; + /** + * @brief Test-only: current oscillator phase [0,1) for a voice slot. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Float phase in [0,1), or 0 if slot is invalid. + */ + float getVoicePhaseForTesting(int slot) const; /** * @brief Test-only: continuous dutySweep delta per sample (0 when duty stepped). * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. */ float getVoiceDutySweepPerSampleForTesting(int slot) const; + + // -- NES frame counter test diagnostics (Hito 2 M3) ------------ + /** + * @brief Test-only: reset the NES frame counter to APU cycles = 0. + * @note Available only when UNIT_TEST is defined. Not for game code. + * + * Mirrors the $4017 write side effect directly: zeros the cycle + * accumulator and current step, clears the IRQ flag and the reset + * pending flag. Does NOT change mode or irqInhibit. + */ + void resetNesFrameCounterForTesting(); + /** + * @brief Test-only: cumulative count of quarter-frame ticks dispatched + * by the frame counter since the last reset. + */ + int getNesFrameCounterQuarterTickCountForTesting() const; + /** + * @brief Test-only: cumulative count of half-frame ticks dispatched + * by the frame counter since the last reset. + */ + int getNesFrameCounterHalfTickCountForTesting() const; + /** + * @brief Test-only: cumulative count of linear-counter ticks dispatched + * by the frame counter since the last reset. + */ + int getNesFrameCounterLinearTickCountForTesting() const; + /** + * @brief Test-only: cumulative count of sweep-unit ticks dispatched + * by the frame counter since the last reset. + */ + int getNesFrameCounterSweepTickCountForTesting() const; + /** + * @brief Test-only: cumulative count of IRQ-flag false→true transitions + * since the last reset. Does NOT count repeated ticks where the + * flag was already set. + */ + int getNesFrameCounterIrqSetCountForTesting() const; + /** + * @brief Test-only: current APU-cycle accumulator of the frame counter. + */ + double getNesFrameCounterApuCyclesForTesting() const; + /** + * @brief Test-only: current step of the frame counter (0 = wrap point). + */ + int getNesFrameCounterCurrentStepForTesting() const; + + // -- NES length counter test diagnostics (Hito 2 M4) --------- + /** + * @brief Test-only: current `counter` value of a voice's NES length counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Counter value in [0, 254], or 0 if slot is invalid. + */ + uint16_t getVoiceNesLengthCounterForTesting(int slot) const; + /** + * @brief Test-only: 0..31 NES LUT index currently loaded on a voice. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return NES length index in [0, 31], or 0 if slot is invalid. + */ + uint8_t getVoiceNesLengthIndexForTesting(int slot) const; + /** + * @brief Test-only: current `halt` flag of a voice's NES length counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Halt flag, or false if slot is invalid. + */ + bool getVoiceNesLengthHaltForTesting(int slot) const; + /** + * @brief Test-only: whether a voice's NES channel is enabled ($4015). + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Channel enable flag, or false if slot is invalid. + */ + bool getVoiceNesChannelEnabledForTesting(int slot) const; + + // -- NES linear counter test diagnostics (Hito 2 M5) ----------- + /** + * @brief Test-only: current `counter` value of a voice's NES linear counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Counter value in [0, 127], or 0 if slot is invalid. + */ + uint8_t getVoiceNesLinearCounterForTesting(int slot) const; + /** + * @brief Test-only: 7-bit reload value currently loaded on a voice's linear counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Reload value in [0, 127], or 0 if slot is invalid. + */ + uint8_t getVoiceNesLinearReloadValueForTesting(int slot) const; + /** + * @brief Test-only: current `reloadFlag` of a voice's NES linear counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Reload flag, or false if slot is invalid. + */ + bool getVoiceNesLinearReloadFlagForTesting(int slot) const; + /** + * @brief Test-only: current `linearEnabled` flag of a voice's NES linear counter. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Linear enabled flag, or false if slot is invalid. + */ + bool getVoiceNesLinearEnabledForTesting(int slot) const; + + // -- NES sweep unit test diagnostics (Hito 2 M6) ----------- + /** + * @brief Test-only: current 11-bit NES timer period of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Timer period in [0, 2047], or 0 if slot is invalid. + */ + uint16_t getVoiceNesSweepTimerForTesting(int slot) const; + /** + * @brief Test-only: current divider counter of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Divider counter, or 0 if slot is invalid. + */ + uint8_t getVoiceNesSweepDividerForTesting(int slot) const; + /** + * @brief Test-only: E bit of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return sweepEnabled flag, or false if slot is invalid. + */ + bool getVoiceNesSweepEnabledForTesting(int slot) const; + /** + * @brief Test-only: N bit (negate) of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Negate flag, or false if slot is invalid. + */ + bool getVoiceNesSweepNegateForTesting(int slot) const; + /** + * @brief Test-only: SSS (shift) of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Shift amount in [0, 7], or 0 if slot is invalid. + */ + uint8_t getVoiceNesSweepShiftForTesting(int slot) const; + /** + * @brief Test-only: continuously-evaluated `muted` flag of a voice's sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return true if the voice is currently silenced by the sweep muting + * conditions (current period < 8 OR target > 0x7FF), even + * when the sweep is "disabled" (E=0 or SSS=0 — the NES bug). + */ + bool getVoiceNesSweepMutedForTesting(int slot) const; + /** + * @brief Test-only: per-voice opt-in flag of the NES sweep unit. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return true if the voice is under NES sweep control. Distinct + * from `getVoiceNesSweepEnabledForTesting`, which reports + * the `$4001`/`$4005` E bit. + */ + bool getVoiceNesSweepUnitEnabledForTesting(int slot) const; + + // -- NES envelope unit test diagnostics (Hito 2 M7) --------- + /** + * @brief Test-only: current `output` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return 0..15 (constVolume ? volume : decayLevel), or 0 if slot invalid. + */ + uint8_t getVoiceNesEnvelopeOutputForTesting(int slot) const; + /** + * @brief Test-only: current `decayLevel` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Decay level in [0, 15], or 0 if slot invalid. + */ + uint8_t getVoiceNesEnvelopeDecayLevelForTesting(int slot) const; + /** + * @brief Test-only: current `dividerCounter` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Divider counter, or 0 if slot invalid. + */ + uint8_t getVoiceNesEnvelopeDividerForTesting(int slot) const; + /** + * @brief Test-only: `startFlag` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Start flag, or false if slot invalid. + */ + bool getVoiceNesEnvelopeStartFlagForTesting(int slot) const; + /** + * @brief Test-only: `envelopeEnabled` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Envelope enable flag, or false if slot invalid. + */ + bool getVoiceNesEnvelopeEnabledForTesting(int slot) const; + /** + * @brief Test-only: `constVolume` of a voice's NES envelope. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns false. + * @return Const volume flag, or false if slot invalid. + */ + bool getVoiceNesEnvelopeConstVolumeForTesting(int slot) const; + /** + * @brief Test-only: VVVV of a voice's NES envelope (constant volume + * level, and the divider reload value in decay mode). + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 0. + * @return Volume in [0, 15], or 0 if slot is invalid. + */ + uint8_t getVoiceNesEnvelopeVolumeForTesting(int slot) const; + + // -- NES NOISE period LUT test diagnostics (Hito 3 M8) --------- + /** + * @brief Test-only: current NOISE LUT index of a voice. + * @note Available only when UNIT_TEST is defined. Not for game code. + * @param slot Voice index [0, MAX_VOICES); out-of-range returns 255. + * @return 0..15 when a LUT entry is armed, 255 when off. + */ + uint8_t getVoiceNesNoiseLutIndexForTesting(int slot) const; + + // -- Master HPF test diagnostics (Hito 4 M10) ------------------ + /** @brief Test-only: current HPF feedback coefficient R. */ + float getHpfRForTesting() const; + /** @brief Test-only: Q15 mirror of the HPF coefficient. */ + int32_t getHpfRQ15ForTesting() const; + /** @brief Test-only: false when the HPF is bypassed. */ + bool getHpfEnabledForTesting() const; + /** @brief Test-only: requested corner in Hz; negative = legacy coefficient. */ + float getHpfCutoffHzForTesting() const; + + // -- Soft-clip test diagnostics (Hito 4 M11) ------------------- + /** @brief Test-only: current master output shaping curve. */ + SoftClipMode getSoftClipModeForTesting() const; #endif private: @@ -289,9 +900,10 @@ namespace pixelroot32::audio { uint8_t masterBitcrushBits_ = 0; // -- Anti-click + DC removal -------------------------------------- - // Single-pole high-pass filter (R ~ 0.995 @ 22 kHz ≈ 35 Hz cutoff), - // approximates the NES APU's output HPF and prevents DC from - // asymmetric pulse duties. + // Single-pole high-pass filter `y[n] = x[n] - x[n-1] + R*y[n-1]`, + // approximating the NES APU's output HPF and removing the DC that + // asymmetric pulse duties introduce. Hito 4 M10 made the corner + // configurable; see `setHpfCutoffHz`. float hpfPrevIn = 0.0f; float hpfPrevOut = 0.0f; @@ -299,6 +911,37 @@ namespace pixelroot32::audio { int32_t hpfPrevInQ15 = 0; int32_t hpfPrevOutQ15 = 0; + // -- HPF configuration (Hito 4 M10) -------------------------------- + /** Historical fixed coefficient: ~35.2 Hz at 44100, ~17.6 Hz at 22050. */ + static constexpr float kHpfLegacyR = 0.995f; + /** Q15 mirror of `kHpfLegacyR` (0.995 * 32768 = 32604.16). */ + static constexpr int32_t kHpfLegacyRQ15 = 32604; + + /** + * Requested corner in Hz. Negative is the sentinel for "use the + * legacy fixed coefficient", which is the default so the canonical + * golden PCM is bit-identical. 0 means the filter is bypassed. + */ + float hpfCutoffHz_ = -1.0f; + bool hpfEnabled_ = true; + float hpfR_ = kHpfLegacyR; + int32_t hpfRQ15_ = kHpfLegacyRQ15; + + // -- Soft-clip configuration (Hito 4 M11) -------------------------- + /** + * Default `Rational` keeps the historical curve. On the integer + * path that specifically means keeping `audio_mixer_lut`, which is + * pre-fitted to it, so the canonical golden PCM is unchanged. + */ + SoftClipMode softClipMode_ = SoftClipMode::Rational; + + /** + * Derive `hpfR_` / `hpfRQ15_` from `hpfCutoffHz_` and the current + * sample rate. Called by `init` (so a configured corner follows a + * rate change) and by `setHpfCutoffHz`. Never called per sample. + */ + void recomputeHpfCoefficients(); + // -- Profiling ring buffer (thread-safe offload) -------------------- ProfileEntry profileRing[PROFILE_RING_SIZE]; uint8_t profileHead = 0; @@ -344,6 +987,37 @@ namespace pixelroot32::audio { // -- Post-mix hook ------------------------------------------------- void (*postMixMono_)(int16_t* mono, int length, void* user) = nullptr; void* postMixUser_ = nullptr; + + // -- NES frame counter (Hito 2 M3) ----------------------------- + // Default-constructed: mode 0, currentStep 0, all clocks off. + NesFrameCounter frameCounter_; + + /** Tick the NES envelopes (one of the four quarter-clock consumers). */ + void tickAllNesEnvelopes(); + /** Tick the NES linear counters (quarter-clock consumer). */ + void tickAllNesLinearCounters(); + /** Tick the NES length counters (half-clock consumer). */ + void tickAllNesLengthCounters(); + /** Tick the NES sweep units (half-clock consumer). */ + void tickAllNesSweepUnits(); + + /** + * Hito 2 M3: per-sample tick of the NES frame counter. + * `apuCyclesPerSample` is computed once per generateSamples() call + * and passed in to avoid a per-sample division. No-op when the mode + * is not 0 or 1. + */ + void tickNesFrameCounter(double apuCyclesPerSample); + +#if defined(UNIT_TEST) + // Test-only instrumentation: counters incremented in the stubs above + // so tests can verify the frame-counter dispatch rate. + int nesQuarterTickCountForTesting_ = 0; + int nesHalfTickCountForTesting_ = 0; + int nesLinearCounterTickCountForTesting_ = 0; + int nesSweepTickCountForTesting_ = 0; + int nesIrqSetCountForTesting_ = 0; +#endif }; } // namespace pixelroot32::audio diff --git a/include/pixelroot32/apu/AudioMixerLUT.h b/include/pixelroot32/apu/AudioMixerLUT.h index fdd6bbb..5ebb8e6 100644 --- a/include/pixelroot32/apu/AudioMixerLUT.h +++ b/include/pixelroot32/apu/AudioMixerLUT.h @@ -15,8 +15,14 @@ namespace pixelroot32::audio { * Resolution: 1025 entries, index = (suma + 131072) >> 8 * * Curve: LUT[i] ≈ 32767 * x / (1 + |x|*0.5) where x = sum * 1.6 / 131072. - * This matches the FPU mixing path when each channel is pre-scaled by 0.4 - * before summation (see ApuCore::generateSamples). + * + * The input is the RAW sum of channels, NOT pre-scaled by MIXER_SCALE: + * the mapping applies the 0.4 itself, so four full-scale channels + * (sum = 131072) land at x = 1.6, which is exactly the bound of the FPU + * path's post-MIXER_SCALE accumulator — hence LUT[0] = -29126, the value + * of the curve at -1.6 rather than at -1.0. Pre-scaling the channels as + * well applies 0.4 twice and leaves the outer 60% of the table + * unreachable (see ApuCore::generateSamples, Hito 5 M12). * * Marked `inline constexpr` (C++17) so every translation unit shares the * same storage — avoids multiple ~2 KB .rodata copies that the default diff --git a/include/pixelroot32/apu/AudioMusicTypes.h b/include/pixelroot32/apu/AudioMusicTypes.h index 4675ac7..05dd9de 100644 --- a/include/pixelroot32/apu/AudioMusicTypes.h +++ b/include/pixelroot32/apu/AudioMusicTypes.h @@ -149,12 +149,62 @@ struct InstrumentPreset { float lfoDelay = 0.0f; // seconds before LFO starts // Waveform Refinements - bool noiseShortMode = false; // For NOISE: true = metallic timbre (93-step LFSR) + // NES register $400E bit 4 (T) maps INVERTED to this flag: + // noiseLfsrShort == true <-> NES T = 0 (noisy/metallic, 93-step, tap bit 6) + // noiseLfsrShort == false <-> NES T = 1 (tone mode, 32767-step, tap bit 1) + // Short alias retained for ABI stability; will be removed in a future major. + // The alias itself is the LAST member of this struct, not here — see the + // note on `noiseShortMode` below for why. + bool noiseLfsrShort = false; // For NOISE: true = metallic timbre (93-step LFSR) float dutySweep = 0.0f; // For PULSE: duty cycle change per second // Optional pitch sweep (materialized to AudioEvent.sweep* when both > 0) float pitchSweepEndHz = 0.0f; // End frequency in Hz; 0 = inactive float pitchSweepDurationSec = 0.0f; // Sweep duration in seconds; 0 = inactive + + /** + * Hito 2 M17. When true, `initVoiceFromEvent` resets the voice's NES + * sub-unit fields to the canonical silent state from the nesdev + * APU_basics `@regs` array before the note starts, so NES mode always + * begins from a known point instead of inheriting whatever the previous + * note left on that slot. + * + * It is a clean slate, NOT an enable: the per-voice opt-in flags of the + * length counter, envelope, sweep and linear counter are all left off. + * Arm the ones you want afterwards with the `setVoiceNes*` setters or + * `AudioCommandType::TRIGGER_NES_LENGTH`. + * + * Only PULSE, TRIANGLE and NOISE are affected; SINE and SAW have no NES + * counterpart and are left untouched. Default false, so the rendered PCM + * is unchanged for every existing preset. + * + * Keep this near the end: the shipped `INSTR_*` presets and the test + * literals use positional aggregate initialization. + */ + bool nesAccurate = false; + + /** + * @deprecated Use `noiseLfsrShort`. Will be removed in a future major. + * + * Deliberately the LAST member, and it must stay there. Both consumers + * (PixelRoot32-Game-Engine and PixelRoot32-Tool-Suite) build presets with + * POSITIONAL aggregate initializers written against the 1.0.1 field order, + * where position 14 was the sole noise-mode flag and 15/16/17 were + * `dutySweep` / `pitchSweepEndHz` / `pitchSweepDurationSec`. + * + * Introducing the alias next to `noiseLfsrShort` pushed all three of those + * down one slot, so every such literal either failed to compile (a float + * landing on this bool) or silently rebound its trailing values. Keeping + * the alias here leaves positions 1..17 meaning exactly what they meant in + * 1.0.1, so consumer literals keep working untouched. + * + * Consequence for anyone editing the `INSTR_*` presets below: the alias no + * longer sits next to the field it mirrors, so it is easy to forget. It is + * NOT auto-synced at the struct level (both are plain independent bools); + * `ApuCore::initVoiceFromEvent` only syncs the copies on `AudioChannel`. + * `test_shipped_presets_keep_deprecated_alias_in_sync` guards this. + */ + bool noiseShortMode = false; }; constexpr InstrumentPreset INSTR_PULSE_LEAD{ @@ -171,7 +221,7 @@ constexpr InstrumentPreset INSTR_PULSE_LEAD{ 5.0f, // lfoFrequency (Hz) 0.025f, // lfoDepth – slightly more vibrato 0.15f, // lfoDelay – delayed vibrato for natural feel - false, // noiseShortMode (unused for pulse) + false, // noiseLfsrShort (unused for pulse) 0.0f, // dutySweep – no sweep by default 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -191,7 +241,7 @@ constexpr InstrumentPreset INSTR_TRIANGLE_LEAD{ 4.0f, // lfoFrequency (Hz) – gentle vibrato 0.020f, // lfoDepth – subtle vibrato 0.20f, // lfoDelay – delayed vibrato - false, // noiseShortMode (unused) + false, // noiseLfsrShort (unused) 0.0f, // dutySweep 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -211,7 +261,7 @@ constexpr InstrumentPreset INSTR_TRIANGLE_PAD{ 2.5f, // lfoFrequency (Hz) – slow modulation 0.15f, // lfoDepth – subtle volume movement 0.50f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort 0.0f, // dutySweep 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -231,7 +281,7 @@ constexpr InstrumentPreset INSTR_PULSE_PAD{ 3.0f, // lfoFrequency (Hz) 0.035f, // lfoDepth – noticeable pitch modulation 0.30f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort 0.08f, // dutySweep – gentle PWM movement for evolving texture 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -251,7 +301,7 @@ constexpr InstrumentPreset INSTR_PULSE_HARMONY{ 6.0f, // lfoFrequency (Hz) 0.30f, // lfoDepth – 30 % volume modulation 0.0f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort 0.15f, // dutySweep – more pronounced PWM-like movement 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -271,7 +321,7 @@ constexpr InstrumentPreset INSTR_TRIANGLE_BASS{ 0.0f, // lfoFrequency 0.0f, // lfoDepth 0.0f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort 0.0f, // dutySweep 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -291,7 +341,7 @@ constexpr InstrumentPreset INSTR_PULSE_BASS{ 0.0f, // lfoFrequency 0.0f, // lfoDepth 0.0f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort 0.0f, // dutySweep 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -311,7 +361,7 @@ constexpr InstrumentPreset INSTR_KICK{ 0.0f, 0.0f, 0.0f, - false, // noiseShortMode – kick is not metallic + false, // noiseLfsrShort – kick is not metallic 0.0f, // dutySweep 0.0f, // pitchSweepEndHz 0.0f // pitchSweepDurationSec @@ -331,10 +381,12 @@ constexpr InstrumentPreset INSTR_SNARE{ 0.0f, 0.0f, 0.0f, - true, // noiseShortMode – metallic 93‑step LFSR for snare + true, // noiseLfsrShort – metallic 93‑step LFSR for snare 0.0f, // dutySweep 0.0f, // pitchSweepEndHz - 0.0f // pitchSweepDurationSec + 0.0f, // pitchSweepDurationSec + false, // nesAccurate + true // noiseShortMode (deprecated alias, last member — keep in sync) }; constexpr InstrumentPreset INSTR_HIHAT{ @@ -351,10 +403,12 @@ constexpr InstrumentPreset INSTR_HIHAT{ 0.0f, 0.0f, 0.0f, - true, // noiseShortMode – metallic timbre for closed hat + true, // noiseLfsrShort – metallic timbre for closed hat 0.0f, // dutySweep 0.0f, // pitchSweepEndHz - 0.0f // pitchSweepDurationSec + 0.0f, // pitchSweepDurationSec + false, // nesAccurate + true // noiseShortMode (deprecated alias, last member — keep in sync) }; /** diff --git a/include/pixelroot32/apu/AudioTypes.h b/include/pixelroot32/apu/AudioTypes.h index bcfbed8..8677aca 100644 --- a/include/pixelroot32/apu/AudioTypes.h +++ b/include/pixelroot32/apu/AudioTypes.h @@ -113,6 +113,29 @@ namespace pixelroot32::audio { /** Pitch/period sweep interpolation curve (default Linear keeps legacy behavior). */ enum class SweepCurve : uint8_t { Linear = 0, Exponential = 1 }; + /** + * @brief Master output shaping curve applied to the summed mix. + * + * Runs on the accumulated mix `S` BEFORE the DC blocker, so the filter + * sees whatever the curve produced — which is what makes `HardClip` + * sound like the NES: its DAC saturates and the analog high-pass then + * filters the already-squared-off signal. + * + * `Rational` is the default and reproduces the historical behaviour + * bit-for-bit, including the pre-fitted `audio_mixer_lut` on the + * integer path. Hito 4 M11. + */ + enum class SoftClipMode : uint8_t { + /** No shaping. The mix runs into the final int16 clamp unaltered. */ + None = 0, + /** `y = S / (1 + |S| * MIXER_K)`. Historical default. */ + Rational = 1, + /** `y = S * (27 + S^2) / (27 + 9*S^2)`, a rational tanh approximation. */ + Tanh = 2, + /** `y = clamp(S, -1, 1)`. Models the NES DAC saturating. */ + HardClip = 3 + }; + /** * @struct SfxBreakpoint * @brief Timed automation point for SFX duty steps or pitch envelope. @@ -173,6 +196,232 @@ namespace pixelroot32::audio { } }; + /** + * @struct NesFrameCounter + * @brief NES APU frame counter (sequencer) state. + * + * Counts APU cycles (CPU/2) and dispatches quarter/half clocks at the + * step boundaries defined by nes_apu::kMode0ApuCyclesPerStep (4-step, + * mode 0) and nes_apu::kMode1ApuCyclesPerStep (5-step, mode 1). + * + * Default state: enabled=false (counter OFF), mode=0, all clocks + * disabled. Consumers opt in with `ApuCore::setNesFrameCounterMode(0 or 1)`. + * Hito 2 M3. + */ + struct NesFrameCounter { + /** 0 = 4-step (mode 0), 1 = 5-step (mode 1). Default is 0. */ + int mode = 0; + + /** + * When false, the frame counter does not advance and dispatches no + * clocks. Set to true by `ApuCore::setNesFrameCounterMode` and reset + * to false by `ApuCore::reset` (or unit-test reset). Default OFF + * keeps the canonical track PCM byte-for-byte identical. + */ + bool enabled = false; + + /** Current step within the cycle (0 = wrap point, 1..maxStep = active). */ + int currentStep = 0; + + /** Continuous APU-cycle accumulator. Doubled per render sample. */ + double apuCycles = 0.0; + + /** Frame interrupt flag. Set on mode-0 cycle wrap (if not inhibited). */ + bool irqFlag = false; + + /** $4017 bit 6 (inhibit). When true, the IRQ flag is cleared and not set. */ + bool irqInhibit = false; + + /** + * Reset pending: when true, the next "step entry" zeroes the cycle + * accumulator and fires quarter+half (mirrors $4017 M=1 side effect). + */ + bool resetPending = false; + }; + + /** + * @struct NesLengthCounter + * @brief NES APU length counter state for a single voice. + * + * The length counter is a half-frame clocked countdown. Once loaded + * (via `ApuCore::setVoiceNesLength`) it decrements at ~120 Hz (mode 0) + * or ~96 Hz (mode 1). When the counter reaches 0, the voice is silenced + * directly. While `halt` is set, the counter is frozen (does not + * decrement). + * + * The `enabled` field mirrors the NES channel enable bit ($4015): + * when clear, the counter is forced to 0 and the voice is silenced. + * When set, the counter can be loaded and decremented. + * + * Default state: enabled=false, counter=0, halt=false. SINE/SAW voices + * keep `enabled=false` permanently and are unaffected by the dispatch + * loop. Hito 2 M4. + * + * Source: https://www.nesdev.org/wiki/APU_Length_Counter + */ + struct NesLengthCounter { + /** Mirrors $4015 channel-enable bit. False = forced silence, true = armed. */ + bool enabled = false; + /** Halt flag (shared with envelope loop / linear counter). True = freeze decrement. */ + bool halt = false; + /** 0..31; the loaded NES LUT index. */ + uint8_t index = 0; + /** Remaining ticks; 0 = silenced on next half-clock. */ + uint16_t counter = 0; + }; + + /** + * @struct NesLinearCounter + * @brief NES APU linear counter state for the TRIANGLE channel. + * + * The linear counter is a quarter-frame clocked 7-bit countdown shared + * with the TRIANGLE channel. A write to $4008 sets the reload value + * (lower 7 bits) and the reload flag. On the next quarter-clock: + * - if `reloadFlag` is set, the counter is loaded from `reloadValue`; + * - else if the counter is > 0, it is decremented; + * - if the shared `control` flag (i.e. `NesLengthCounter::halt`) is + * clear, the reload flag is also cleared. + * + * The shared `halt` flag is read through the `lengthCounter` pointer, + * which is bound in `AudioChannel::reset()`. This avoids duplicating + * the flag and keeps `$4008`'s control bit and `$400B`'s halt bit + * behaviour in lockstep with the canonical NES semantics. + * + * When the linear counter is enabled (`linearEnabled == true`) and the + * counter is 0, the TRIANGLE sequencer is gated per-sample (output 0, + * phase frozen) but the voice is NOT disabled. Re-loading via a + * $4008 write resumes the voice without needing a fresh play event. + * + * Default OFF: `linearEnabled == false` on construction, so the + * counter does not tick and the canonical track PCM is unchanged. + * Hito 2 M5. + * + * Source: https://www.nesdev.org/wiki/APU_Linear_Counter + */ + struct NesLinearCounter { + /** Per-voice opt-in. False = counter does not tick; bypasses the gate. */ + bool linearEnabled = false; + /** Set by $4008 write; cleared on quarter-clock if the shared halt/control flag is 0. */ + bool reloadFlag = false; + /** 7-bit reload value (high bits ignored). */ + uint8_t reloadValue = 0; + /** Current value, decremented at quarter-clock rate (240 Hz NTSC mode 0). */ + uint8_t counter = 0; + /** Bound in AudioChannel::reset() to share the halt/control flag with the length counter. */ + NesLengthCounter* lengthCounter = nullptr; + }; + + /** + * @struct NesSweepUnit + * @brief NES APU sweep unit state for the PULSE channels. + * + * Mirrors the $4001 / $4005 sweep register pair. Operates on the + * 11-bit timer period (NES PULSE NTSC = 111860.8 / (timer+1) Hz). + * Clocks at half-frame rate (120 Hz NTSC mode 0); on each tick the + * unit computes a target period, evaluates the muting conditions, + * and optionally updates `timer` from the target. + * + * The muting flag is evaluated CONTINUOUSLY (the "NES bug" from + * https://www.nesdev.org/wiki/APU_Sweep) — even when `sweepEnabled` + * is false or `shift` is 0, the dispatcher still computes the + * target and updates `muted`. The hot path (PULSE generation) + * checks `muted` per-sample and gates the output to 0. + * + * That continuous evaluation is scoped to voices that opted in via + * `unitEnabled`. Without that gate the rule `period < 8` would fire + * on the default `timer == 0`, so merely starting the frame counter + * would silence every PULSE voice driven by the legacy float + * frequency path. See `unitEnabled` below. + * + * Default state: all fields zero / false. The canonical track never + * enables NES mode, so the rendered PCM is byte-for-byte identical. + * Hito 2 M6. + * + * Source: https://www.nesdev.org/wiki/APU_Sweep + */ + struct NesSweepUnit { + /** + * Per-voice opt-in for the whole sweep unit. NOT a hardware + * register bit — it is the same "does this voice participate in + * the NES model at all" gate as `NesLengthCounter::enabled`, + * `NesLinearCounter::linearEnabled` and + * `NesEnvelope::envelopeEnabled`. Set it with + * `ApuCore::setVoiceNesSweepUnitEnabled`. + * + * INVARIANT: `muted` is only ever true while this is true. + * The per-sample hot path relies on it and tests `muted` alone. + */ + bool unitEnabled = false; + /** E bit ($4001 bit 7 / $4005 bit 7). Enables the timer update path. */ + bool sweepEnabled = false; + /** P (3 bits) — divider period (NTSC mode 0: half-frame rate). */ + uint8_t period = 0; + /** N bit — negate (true = subtract, false = add). */ + bool negate = false; + /** SSS (3 bits) — shift amount. 0 disables the timer-update branch. */ + uint8_t shift = 0; + /** Set by $4001 / $4005 write; cleared on the next half-clock tick. */ + bool reloadFlag = false; + /** Current divider counter. */ + uint8_t dividerCounter = 0; + /** true = PULSE 2 (two's complement negate); false = PULSE 1 (ones' complement negate). */ + bool isPulse2 = false; + /** 11-bit NES timer period (0..2047). Source of truth for the oscillator freq. */ + uint16_t timer = 0; + /** + * Continuously-evaluated muting flag. True when the channel is + * silenced (current period < 8 OR target > 0x7FF). Test-only + * field exposed by the `getVoiceNesSweepMutedForTesting` accessor. + */ + bool muted = false; + }; + + /** + * @struct NesEnvelope + * @brief NES APU envelope unit state for PULSE 1, PULSE 2, and NOISE. + * + * Mirrors the $4000 / $4004 / $400C write side effects (loop flag, + * constant volume flag, VVVV) and the $4003 / $4007 / $400F length + * load side effect (start flag). Clocks at quarter-frame rate + * (240 Hz NTSC mode 0) via the M3 `tickAllNesEnvelopes()` dispatcher. + * + * The loop flag is SHARED with the length counter (`halt` bit): + * `tickAllNesEnvelopes()` reads `lengthCounter->halt` to decide whether + * the decay level should wrap to 15 on underflow (loop) or silence + * the channel (!loop). The `lengthCounter` pointer is bound in + * `AudioChannel::reset()` so the two sub-units see the same bit. + * + * When `envelopeEnabled == true` on a PULSE or NOISE voice, the + * envelope's `output` (0..15) REPLACES the ADSR `envelope.currentLevel` + * as the per-sample volume source. When the envelope reaches `0` and + * loop is clear, the voice is silenced directly (matches the NES + * hardware, which gates the channel on the envelope output). + * + * Default state: `envelopeEnabled == false` on construction, so the + * quarter-frame dispatch is a no-op and the canonical track PCM + * remains byte-for-byte identical. Hito 2 M7. + * + * Source: https://www.nesdev.org/wiki/APU_Envelope + */ + struct NesEnvelope { + /** Per-voice opt-in. False = quarter-clock dispatch is a no-op for this voice. */ + bool envelopeEnabled = false; + /** Set by $4003 / $4007 / $400F write; cleared on the next quarter-clock. */ + bool startFlag = false; + /** C bit (constant volume flag). True = use VVVV directly as output. */ + bool constVolume = false; + /** VVVV (4 bits) — constant volume (C=1) or divider reload value (C=0). */ + uint8_t volume = 0; + /** Current divider counter, decremented each quarter-clock while > 0. */ + uint8_t dividerCounter = 0; + /** 0..15. Decremented on each divider wrap; wraps to 15 on loop. */ + uint8_t decayLevel = 0; + /** 0..15, computed output = constVolume ? volume : decayLevel. */ + uint8_t output = 0; + /** Bound in AudioChannel::reset() to share the halt/loop flag with the length counter. */ + NesLengthCounter* lengthCounter = nullptr; + }; + /** * @struct AudioChannel * @brief Represents the internal state of a single audio channel. @@ -229,8 +478,73 @@ namespace pixelroot32::audio { uint32_t pitchSegStartAge = 0; /** Length of the active pitch segment in samples (0 = hold final value). */ uint32_t pitchSegLenSamples = 0; - uint16_t lfsrState = 0x4000; // NES-style 15-bit LFSR for deterministic noise - bool noiseShortMode = false; // true = 93-step sequence (metallic), false = 32767-step + uint16_t lfsrState = 0x7FFF; // NES-style 15-bit LFSR for deterministic noise. + // Seeded to all-1s (0x7FFF) to match the NES APU state + // after a write to register $400F (length counter load + // resets the LFSR to 0x7FFF). Hito 1 M9. + // NES register $400E bit 4 (T) maps INVERTED to this flag: + // noiseLfsrShort == true <-> NES T = 0 (noisy/metallic, 93-step, tap bit 6) + // noiseLfsrShort == false <-> NES T = 1 (tone mode, 32767-step, tap bit 1) + // Short alias retained for ABI stability; will be removed in a future major. + bool noiseLfsrShort = false; + /** @deprecated Use noiseLfsrShort. Will be removed in a future major version. */ + bool noiseShortMode = false; + + /** + * @brief When type == TRIANGLE: quantize output to 4-bit NES levels (32 steps). + * + * The NES APU drives the triangle wave through a 5-bit linear counter and + * produces 16 unique positive + 16 negative levels (effectively 4-bit + * resolution: -1, -13/15, ..., 13/15, +1). When this flag is true, the + * synthesised triangle is snapped to those 32 levels instead of remaining + * a smooth ramp. + * + * Default is true to match NES-accurate canonical output. Hito 1 M1. + */ + bool triangleQuantize4Bit = true; + + /** + * @brief When type == TRIANGLE: double the effective frequency at + * initVoiceFromEvent time, matching the NES convention where TRIANGLE + * is exactly one octave below PULSE for the same period value. + * + * Composers porting a NES piece receive Hz; if the NES period-to-Hz + * mapping produced 110 Hz on PULSE for the same input, the TRIANGLE + * voice would be 55 Hz. With this flag, `event.frequency` is treated + * as the PULSE-equivalent and the TRIANGLE voice is doubled to 110 Hz. + * + * Default false: callers that already account for the octave offset + * should leave it off. Hito 1 M16. + */ + bool triangleOctaveUp = false; + + /** + * @brief When type == PULSE: 0..3 selects the NES discrete duty pattern + * (12.5% / 25% / 50% / 75%); 255 = use continuous `dutyCycle` (default). + * + * 255 acts as a sentinel so a legitimate `0` index (12.5% duty) is + * distinguishable from the unset / continuous default. Out-of-range + * values (1..254, >3 except 255) are treated as continuous by the + * generator; set this per voice via `ApuCore::setVoicePulseDutyMode`. + * + * Hito 1 M2. + */ + uint8_t pulseDutyIndex = 255; + + /** + * NOISE LUT index: 0..15 select a NES noise period from + * `nes_apu::kNesNoisePeriodLutNtsc`; 255 = derive the period from + * `frequency` (default, legacy behaviour). + * + * Same sentinel scheme as `pulseDutyIndex`, and the same lifetime: + * it is a per-slot mode set by the app via + * `ApuCore::setVoiceNesNoiseLutMode`, not a per-note value, so it + * survives new notes and is cleared only by `reset()`. Out-of-range + * values (16..254) are stored as 255 by the setter. + * + * Hito 3 M8. + */ + uint8_t nesNoiseLutIndex = 255; /** Samples until next LFSR step on NOISE; `frequency` sets noise clock rate (not pitch). */ uint32_t noisePeriodSamples = 1; @@ -254,10 +568,102 @@ namespace pixelroot32::audio { int32_t sweepLogStartQ16 = 0; ///< Q15 path Exponential: log2(start) in Q16. int32_t sweepLogDeltaQ16 = 0; ///< Q15 path Exponential: log2(end/start) in Q16. + /** + * @brief NES APU length counter state for this voice. + * + * The NES length counter is a per-voice half-frame clocked countdown + * that, when it reaches 0, silences the voice directly. SINE/SAW + * slots keep `enabled = false` permanently and are unaffected by the + * dispatch loop; only NES voices (PULSE/TRIANGLE/NOISE) opt in. + * + * Default OFF: the canonical track never touches this field, so the + * rendered PCM is byte-for-byte identical. Hito 2 M4. + * + * Note: appended at the END of AudioChannel to keep aggregate + * initializers in test literals positionally stable (see Hito 0/1 + * lessons on aggregate-initializer hazards). + */ + NesLengthCounter nesLengthCounter; + + /** + * @brief NES APU linear counter state for this voice (TRIANGLE). + * + * SINE/SAW/PULSE/NOISE voices keep `linearEnabled = false` + * permanently and are unaffected by the quarter-clock dispatch. + * Only TRIANGLE voices opt in via `setVoiceNesLinearEnabled`. + * + * `lengthCounter` is bound in `reset()` to point at this voice's + * `nesLengthCounter`, so the shared halt/control flag is read in + * one place (the length counter) and the linear counter picks it + * up via pointer. + * + * Default OFF: `linearEnabled == false` on construction, so the + * counter does not tick and the canonical track PCM is unchanged. + * Hito 2 M5. + * + * Note: appended at the END of AudioChannel to keep aggregate + * initializers in test literals positionally stable (see Hito 0/1 + * lessons on aggregate-initializer hazards). + */ + NesLinearCounter nesLinearCounter; + + /** + * @brief NES APU sweep unit state for this voice (PULSE). + * + * SINE/SAW/TRIANGLE/NOISE voices keep `sweepEnabled = false` and + * the half-clock dispatch skips them. Only PULSE voices opt in + * via `setVoiceNesSweep`. + * + * Default OFF: all fields zero / false on construction, so the + * canonical track PCM remains byte-for-byte identical. Hito 2 M6. + * + * Note: appended at the END of AudioChannel to keep aggregate + * initializers in test literals positionally stable (see Hito 0/1 + * lessons on aggregate-initializer hazards). + */ + NesSweepUnit nesSweepUnit; + + /** + * @brief NES APU envelope unit state for this voice (PULSE / NOISE). + * + * SINE/SAW/TRIANGLE voices keep `envelopeEnabled = false` and the + * quarter-clock dispatch skips them. Only PULSE and NOISE voices + * opt in via `setVoiceNesEnvelopeEnabled`. + * + * `lengthCounter` is bound in `reset()` to point at this voice's + * `nesLengthCounter`, so the shared loop/halt flag is read in one + * place (the length counter) and the envelope picks it up via + * pointer. + * + * Default OFF: `envelopeEnabled == false` on construction, so the + * quarter-frame dispatch is a no-op and the canonical track PCM + * remains byte-for-byte identical. Hito 2 M7. + * + * Note: appended at the END of AudioChannel to keep aggregate + * initializers in test literals positionally stable (see Hito 0/1 + * lessons on aggregate-initializer hazards). + */ + NesEnvelope nesEnvelope; + /** * @brief Resets the channel to a clean disabled state. */ void reset() { + nesLengthCounter = NesLengthCounter{}; + // Hito 2 M5: bind the shared halt/control flag pointer for the + // linear counter. The pointer survives subsequent assignments + // to `nesLengthCounter` because the binding is re-asserted on + // every reset() call. + nesLinearCounter = NesLinearCounter{}; + nesLinearCounter.lengthCounter = &nesLengthCounter; + // Hito 2 M6: zero the NES sweep unit. Default OFF keeps the + // canonical track PCM byte-for-byte identical. + nesSweepUnit = NesSweepUnit{}; + // Hito 2 M7: zero the NES envelope unit and bind the shared + // loop/halt pointer to the length counter. Default OFF keeps + // the canonical track PCM byte-for-byte identical. + nesEnvelope = NesEnvelope{}; + nesEnvelope.lengthCounter = &nesLengthCounter; enabled = false; type = WaveType::PULSE; phase = 0.0f; @@ -281,8 +687,13 @@ namespace pixelroot32::audio { volume = 0.0f; remainingSamples = 0; loop = false; - lfsrState = 0x4000; // Initialize LFSR to non-zero state + lfsrState = 0x7FFF; // Initialize LFSR to NES-equivalent all-1s seed (Hito 1 M9) + noiseLfsrShort = false; noiseShortMode = false; + triangleQuantize4Bit = true; // Hito 1 M1: 4-bit NES levels default ON + triangleOctaveUp = false; // Hito 1 M16: octave doubling opt-in + pulseDutyIndex = 255; // Hito 1 M2: 255 = continuous (legacy default) + nesNoiseLutIndex = 255; // Hito 3 M8: 255 = derive period from frequency noisePeriodSamples = 1; noiseCountdown = 0; sweepSamplesTotal = 0; @@ -361,6 +772,41 @@ namespace pixelroot32::audio { // --- Command Types --- + /** + * @struct VoiceNesOptions + * @brief Every per-voice NES opt-in in one place (Hito 4 M14). + * + * Each field already has a dedicated setter; this bundles them so a + * caller can put a voice into (or out of) NES mode in a single atomic + * write, and so the whole configuration can cross the game/audio thread + * boundary through `AudioCommandType::SET_NES_OPTIONS`. The individual + * setters remain the right tool for changing one thing. + * + * All defaults are "off", so a default-constructed instance disarms a + * voice completely. + * + * NOTE: `noiseLfsrShort` is deliberately NOT here even though the + * original M14 sketch listed it. That flag is owned per NOTE by + * `InstrumentPreset` and rewritten by `initVoiceFromEvent` on every + * trigger, unlike the per-SLOT modes below. Putting it in this struct + * would give one field two owners, and the preset would silently win on + * the next note. + */ + struct VoiceNesOptions { + /** Arms the M4 length counter (`$4015` channel enable). */ + bool lengthCounterEnabled = false; + /** Arms the M5 linear counter (TRIANGLE only). */ + bool linearCounterEnabled = false; + /** Arms the M7 envelope (PULSE / NOISE only). */ + bool envelopeEnabled = false; + /** Arms the M6 sweep unit (PULSE only). NOT the `$4001` E bit. */ + bool sweepUnitEnabled = false; + /** M2 duty mode: 0..3 select NES patterns, 255 = continuous. */ + uint8_t pulseDutyIndex = 255; + /** M8 noise period: 0..15 select a LUT entry, 255 = from frequency. */ + uint8_t noiseLutIndex = 255; + }; + enum class AudioCommandType : uint8_t { PLAY_EVENT, STOP_CHANNEL, @@ -376,6 +822,17 @@ namespace pixelroot32::audio { MUSIC_SET_BPM, /// Reposition music sequencer to an elapsed-tick offset from piece start. MUSIC_SEEK, + /** + * Hito 2 M13: arm a voice's NES length counter from the audio + * command queue. Reads `channelIndex`, `nesLengthIndex` and + * `nesLengthHalt`. See `ApuCore::setVoiceNesLength`. + */ + TRIGGER_NES_LENGTH, + /** + * Hito 4 M14: apply a whole `VoiceNesOptions` to a voice from the + * audio command queue. Reads `channelIndex` and `nesOptions`. + */ + SET_NES_OPTIONS, }; // Forward declaration for MusicTrack @@ -402,6 +859,12 @@ namespace pixelroot32::audio { uint8_t masterBitcrushBits = 0; /** Used when type == MUSIC_SEEK: elapsed ticks from music play start. */ uint64_t seekOffsetTicks = 0; + /** Used when type == TRIGGER_NES_LENGTH: LUT index (clamped 0–31). */ + uint8_t nesLengthIndex = 0; + /** Used when type == TRIGGER_NES_LENGTH: halt flag latched on the voice. */ + bool nesLengthHalt = false; + /** Used when type == SET_NES_OPTIONS: the options to apply. */ + VoiceNesOptions nesOptions{}; const MusicTrack* subTracks[MAX_SUB_TRACKS]; size_t subTrackCount; @@ -412,6 +875,9 @@ namespace pixelroot32::audio { : type(AudioCommandType::STOP_CHANNEL), masterBitcrushBits(0), seekOffsetTicks(0), + nesLengthIndex(0), + nesLengthHalt(false), + nesOptions(), subTracks{nullptr, nullptr, nullptr}, subTrackCount(0) { std::memset(static_cast(&event), 0, sizeof(event)); diff --git a/library.json b/library.json index a9a27ae..981b0d4 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "PixelRoot32-APU", - "version": "1.0.1", + "version": "2.0.0", "description": "Shared NES-style APU (Audio Processing Unit): 8-voice synthesis core, music sequencer, mixer and command queue used by the PixelRoot32 Game Engine and the PixelRoot32 Tool Suite.", "keywords": ["audio", "apu", "chiptune", "nes", "synthesis", "esp32", "pixelroot32"], "license": "MIT", diff --git a/src/ApuCore.cpp b/src/ApuCore.cpp index cdf4ec6..7506ae0 100644 --- a/src/ApuCore.cpp +++ b/src/ApuCore.cpp @@ -21,12 +21,42 @@ namespace pixelroot32::audio { // ======================================================================================================== // Wave generator lambdas (moved to file scope for branch-free dispatch) // ======================================================================================================== + // NES PULSE duty patterns (8-step, 1=high, 0=low). Mirrors the canonical + // table on https://www.nesdev.org/wiki/APU_Pulse. Index 0..3 maps to + // 12.5% / 25% / 50% / 75% high time respectively. Hito 1 M2. + static const uint8_t kNesPulseDuty[4][8] = { + {1,0,0,0,0,0,0,0}, // 12.5% (1/8 high) + {0,1,1,0,0,0,0,0}, // 25% (2/8 high) + {0,1,1,1,1,0,0,0}, // 50% (4/8 high) + {1,0,1,1,1,1,1,0}, // 75% (6/8 high) + }; + static auto generatePulseSampleQ15 = [](AudioChannel& ch) -> int32_t { + if (ch.pulseDutyIndex < 4) { + // phaseQ32 ranges [0, 2^32); the high 3 bits form the 8-step index + // (top bit cleared at 2^32 so values map to 0..7). + const uint32_t step = (ch.phaseQ32 >> 29) & 7u; + return kNesPulseDuty[ch.pulseDutyIndex][step] ? 32767 : -32767; + } return (ch.phaseQ32 < ch.dutyCycleQ32) ? 32767 : -32767; }; static auto generateTriangleSampleQ15 = [](AudioChannel& ch) -> int32_t { const uint32_t p16 = ch.phaseQ32 >> 16; - return (p16 < 32768u) ? ((int32_t)(p16 * 2) - 32768) : (32768 - (int32_t)((p16 - 32768u) * 2)); + int32_t tri_q15 = (p16 < 32768u) + ? ((int32_t)(p16 << 1) - 32768) + : (32768 - (int32_t)((p16 - 32768u) << 1)); + if (ch.triangleQuantize4Bit) { + // NES TRIANGLE: 16 levels on each side. Q15 step = 2 * 32768 / 15. + // Rounding pattern matches the float path (banker's-style +0.5*step). + constexpr int32_t kStep = (2 * 32768) / 15; // = 4369 + constexpr int32_t kHalf = kStep / 2; // = 2184 + int32_t shifted = tri_q15 + 32768 + kHalf; // [0..65536+kHalf] + int32_t idx = shifted / kStep; + if (idx < 0) idx = 0; + if (idx > 15) idx = 15; + tri_q15 = idx * kStep - 32768; + } + return tri_q15; }; static auto generateSawSampleQ15 = [](AudioChannel& ch) -> int32_t { const int64_t v = ((int64_t)ch.phaseQ32 << 1) - (1LL << 32); @@ -64,6 +94,133 @@ namespace pixelroot32::audio { return (uint32_t)inc; } + /** + * Hito 2 M6: re-sync a voice's float / Q32 phase increments to match + * the current NES PULSE timer period. Used by `setVoiceNesTimer` + * (immediate) and the half-clock dispatch (when the timer is updated). + * No-op when the timer is 0 (no sweep active) or the sample rate is + * invalid. + */ + static inline void syncNesTimerToPhaseIncQ32(Voice& v, int sr) { + if (sr <= 0) return; + if (v.nesSweepUnit.timer == 0) return; + const float hz = nes_apu::nesTimerToHz(v.nesSweepUnit.timer); + v.frequency = hz; + v.phaseIncrement = hz / static_cast(sr); + v.phaseIncQ32 = frequency_hz_to_phase_inc_q32(hz, sr); + v.basePhaseIncQ32 = v.phaseIncQ32; + } + + /** + * Hito 2 M6: recompute the continuously-evaluated `muted` flag for a + * voice's sweep unit. Called by both `setVoiceNesSweep` (after + * updating E/P/N/SSS) and `setVoiceNesTimer` (after updating the + * timer), so the per-sample output gate sees the correct value + * before the next half-clock tick. + * + * The target is checked for > 0x7FF BEFORE clamping (matches the NES + * algorithm — the overflow is the signal for muting). + * + * Voices that have not opted into the sweep unit are forced to + * `muted = false`: the default `timer == 0` would otherwise satisfy + * the "current period < 8" rule and silence a voice that never asked + * for NES sweep behaviour. + */ + static inline void recomputeSweepMuted(Voice& v) { + const NesSweepUnit& sw = v.nesSweepUnit; + if (!sw.unitEnabled) { + v.nesSweepUnit.muted = false; + return; + } + const uint16_t period = sw.timer; + const int32_t change = (sw.shift > 0) ? (period >> sw.shift) : 0; + int32_t target = sw.negate + ? (sw.isPulse2 + ? (int32_t)period - change + : (int32_t)period - change - 1) + : (int32_t)period + change; + if (target < 0) target = 0; + v.nesSweepUnit.muted = (period < 8) || (target > 0x7FF); + } + + /** + * Hito 2 M17: reset a voice's NES sub-unit fields to the canonical + * silent state, replicating the `@regs` array from + * https://www.nesdev.org/wiki/APU_basics: + * + * PULSE $4000=$30 $4001=$08 $4002=$00 $4003=$00 + * TRIANGLE $4008=$80 $400A=$00 $400B=$00 + * NOISE $400C=$30 $400E=$00 $400F=$00 + * + * Decoding the shared byte $30 (DDLC VVVV) gives L=1, C=1, VVVV=0 — + * length counter halted, constant volume selected, volume zero. So the + * voice is silent and nothing counts down on its own. $08 (EPPP NSSS) + * gives E=0, P=0, N=1, SSS=0. $80 sets the TRIANGLE control bit, which + * is the same shared halt flag this codebase stores once on the length + * counter. + * + * On NES the $4003/$400B/$400F write also raises the envelope start + * flag and the linear counter reload flag; `setVoiceNesLength` models + * the same pair. Here the start flag is raised but the linear reload + * flag is explicitly cleared, because $400B is written with a zero + * reload value: reloading from 0 is what the safe state wants, and + * leaving the flag set would make the counter reload every quarter + * clock instead of once. + * + * This is a clean slate, NOT an enable. The per-voice opt-in flags + * (`nesLengthCounter.enabled`, `nesEnvelope.envelopeEnabled`, + * `nesSweepUnit.unitEnabled`, `nesLinearCounter.linearEnabled`) are all + * cleared; callers arm what they need afterwards. + * + * SINE and SAW have no NES counterpart and are left untouched. + */ + static void initNesSafe(Voice& ch, WaveType type) { + const bool isPulse = (type == WaveType::PULSE); + const bool isTriangle = (type == WaveType::TRIANGLE); + const bool isNoise = (type == WaveType::NOISE); + if (!isPulse && !isTriangle && !isNoise) { + return; + } + + // $4015 = $00: the channel starts silenced/unarmed. + NesLengthCounter& lc = ch.nesLengthCounter; + lc.enabled = false; + lc.counter = 0; + lc.index = 0; + lc.halt = true; // L bit of $30 / control bit of $80 + + // $4000 / $400C = $30, and $4008 = $80 for the triangle: constant + // volume selected at level 0. The triangle has no envelope, but the + // fields are zeroed anyway so introspection is deterministic. + NesEnvelope& env = ch.nesEnvelope; + env.envelopeEnabled = false; + env.constVolume = true; + env.volume = 0; + env.decayLevel = 0; + env.dividerCounter = 0; + env.output = 0; + env.startFlag = true; // side effect of the $4003 / $400B / $400F write + + NesSweepUnit& sw = ch.nesSweepUnit; + sw.unitEnabled = false; + sw.sweepEnabled = false; + sw.period = 0; + sw.shift = 0; + sw.dividerCounter = 0; + sw.reloadFlag = false; + sw.timer = 0; + sw.muted = false; + // $4001 = $08 sets N. Only the pulse channels have a sweep unit, so + // the bit is meaningless (and misleading) on the others. + sw.negate = isPulse; + + NesLinearCounter& linc = ch.nesLinearCounter; + linc.linearEnabled = false; + linc.counter = 0; + linc.reloadValue = 0; + linc.reloadFlag = false; + } + /** NOISE LFSR period from clock Hz (minimum 1 sample). */ static inline uint32_t noise_clock_hz_to_period(float hz, int sr) { if (sr <= 0) return 1u; @@ -525,6 +682,97 @@ namespace pixelroot32::audio { sampleRate = (sr > 0) ? sr : 44100; tickDurationSamples = (uint64_t)((float)sampleRate * 60.0f / (tempoBPM * (float)TICKS_PER_BEAT)); + // Hito 4 M10: a configured corner is expressed in Hz, so it has to + // follow the new rate. No-op while the legacy coefficient is in use. + recomputeHpfCoefficients(); + } + + // Hito 4 M10: master HPF configuration. + void ApuCore::recomputeHpfCoefficients() { + if (hpfCutoffHz_ < 0.0f) { + // Legacy: pin the historical coefficient exactly, so the + // canonical golden PCM stays byte-for-byte identical. + hpfR_ = kHpfLegacyR; + hpfRQ15_ = kHpfLegacyRQ15; + return; + } + if (hpfCutoffHz_ == 0.0f || sampleRate <= 0) { + // Bypassed; the coefficient is unused but kept sane. + hpfR_ = kHpfLegacyR; + hpfRQ15_ = kHpfLegacyRQ15; + return; + } + // R = exp(-2*pi*fc/fs). Computed here rather than per sample: this + // runs on configuration changes only, so the transcendental is fine + // even on soft-float targets. + const double r = std::exp(-6.283185307179586 + * (double)hpfCutoffHz_ / (double)sampleRate); + // Clamp strictly inside (0, 1): R >= 1 makes the recursion unstable, + // and R <= 0 is not a meaningful single-pole coefficient. A corner + // above Nyquist lands near 0 and is effectively "block everything". + double clamped = r; + if (!(clamped > 0.0)) clamped = 0.0; // also catches NaN + if (clamped > 0.99999) clamped = 0.99999; + hpfR_ = (float)clamped; + hpfRQ15_ = (int32_t)(clamped * 32768.0 + 0.5); + if (hpfRQ15_ > 32767) hpfRQ15_ = 32767; + if (hpfRQ15_ < 0) hpfRQ15_ = 0; + } + + // Hito 4 M14: bundled per-voice NES options. + void ApuCore::setVoiceNesOptions(int slot, const VoiceNesOptions& opts) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + // Delegate to the dedicated setters so their validation and side + // effects apply unchanged — notably the index clamping and the + // immediate retune a live NOISE voice gets from the LUT setter. + setVoiceNesChannelEnabled(slot, opts.lengthCounterEnabled); + setVoiceNesLinearEnabled(slot, opts.linearCounterEnabled); + setVoiceNesEnvelopeEnabled(slot, opts.envelopeEnabled); + setVoiceNesSweepUnitEnabled(slot, opts.sweepUnitEnabled); + setVoicePulseDutyMode(slot, opts.pulseDutyIndex); + setVoiceNesNoiseLutMode(slot, opts.noiseLutIndex); + } + + VoiceNesOptions ApuCore::getVoiceNesOptions(int slot) const { + VoiceNesOptions opts{}; + if (slot < 0 || slot >= MAX_VOICES) { + return opts; + } + const Voice& v = voices[slot]; + opts.lengthCounterEnabled = v.nesLengthCounter.enabled; + opts.linearCounterEnabled = v.nesLinearCounter.linearEnabled; + opts.envelopeEnabled = v.nesEnvelope.envelopeEnabled; + opts.sweepUnitEnabled = v.nesSweepUnit.unitEnabled; + opts.pulseDutyIndex = v.pulseDutyIndex; + opts.noiseLutIndex = v.nesNoiseLutIndex; + return opts; + } + + // Hito 4 M11: master output shaping curve. + void ApuCore::setSoftClipMode(SoftClipMode mode) { + switch (mode) { + case SoftClipMode::None: + case SoftClipMode::Rational: + case SoftClipMode::Tanh: + case SoftClipMode::HardClip: + softClipMode_ = mode; + break; + default: + break; // unknown value: leave the current curve alone + } + } + + void ApuCore::setHpfCutoffHz(float cutoffHz) { + if (!(cutoffHz > 0.0f)) { // <= 0 or NaN: bypass + hpfCutoffHz_ = 0.0f; + hpfEnabled_ = false; + } else { + hpfCutoffHz_ = cutoffHz; + hpfEnabled_ = true; + } + recomputeHpfCoefficients(); } void ApuCore::reset() { @@ -537,6 +785,12 @@ namespace pixelroot32::audio { masterBitcrushBits_ = 0; hpfPrevIn = hpfPrevOut = 0.0f; hpfPrevInQ15 = hpfPrevOutQ15 = 0; // Q15 state for no-FPU path + // Hito 4 M10: reset() restores configuration as well as state, the + // same way it restores masterVolume and the bitcrush depth. + hpfCutoffHz_ = -1.0f; + hpfEnabled_ = true; + recomputeHpfCoefficients(); + softClipMode_ = SoftClipMode::Rational; // Hito 4 M11 currentPeak = 0.0f; samplesSinceLog = 0; audioTimeSamples = 0; @@ -557,6 +811,21 @@ namespace pixelroot32::audio { droppedCommands.store(0, std::memory_order_release); firstSequencerCallAfterPlay_ = false; clearMusicTrackVoiceState(); + // Hito 2 M3: reset the NES frame counter to its default state. + // mode/irqInhibit are deliberately kept (consumer-owned); the rest + // is zeroed and the counter is disabled. + frameCounter_.enabled = false; + frameCounter_.currentStep = 0; + frameCounter_.apuCycles = 0.0; + frameCounter_.irqFlag = false; + frameCounter_.resetPending = false; +#if defined(UNIT_TEST) + nesQuarterTickCountForTesting_ = 0; + nesHalfTickCountForTesting_ = 0; + nesLinearCounterTickCountForTesting_ = 0; + nesSweepTickCountForTesting_ = 0; + nesIrqSetCountForTesting_ = 0; +#endif } #if defined(UNIT_TEST) @@ -661,6 +930,745 @@ namespace pixelroot32::audio { } return voices[slot].dutySweep; } + + float ApuCore::getVoicePhaseForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0.0f; + } + return voices[slot].phase; + } +#endif // UNIT_TEST — the test-only accessors end here. + + // ===================================================================== + // NES sub-units (Hito 2 M3-M7). PRODUCTION CODE — everything from here + // to the end of tickNesFrameCounter is public API and MUST stay outside + // the UNIT_TEST guard. It once sat inside it, which compiled fine in + // every suite (they all recompile this file with UNIT_TEST=1) while the + // shipped library lost the symbols and no consumer could link. See + // test_library_link, which exists to catch exactly that. + // ===================================================================== + + // -- NES frame counter (Hito 2 M3) ----------------------------------- + void ApuCore::setNesFrameCounterMode(int mode) { + if (mode != 0 && mode != 1) { + // Out-of-range values are silently ignored (no state change). + return; + } + // Idempotent: same mode does not schedule another reset. + if (frameCounter_.mode == mode && frameCounter_.enabled) { + return; + } + frameCounter_.mode = mode; + frameCounter_.enabled = true; + // $4017 side effect: timer reset on the next step entry. + frameCounter_.resetPending = true; + // Mode change also clears the IRQ flag (mirrors hardware). + frameCounter_.irqFlag = false; + } + + void ApuCore::setNesFrameCounterInhibit(bool inhibit) { + frameCounter_.irqInhibit = inhibit; + if (inhibit) { + // $4017 I=1 clears the IRQ flag immediately. + frameCounter_.irqFlag = false; + } + } + + bool ApuCore::getNesFrameCounterIrq() const { + return frameCounter_.irqFlag; + } + + void ApuCore::tickAllNesEnvelopes() { + // Hito 2 M7: NES envelope unit dispatch (quarter-frame consumer). + // + // Iterates the voice pool and ticks the envelope of every voice + // that has `envelopeEnabled == true` and is a PULSE or NOISE + // voice (TRIANGLE / SINE / SAW are skipped; the NES hardware + // has no envelope for them). The algorithm mirrors + // https://www.nesdev.org/wiki/APU_Envelope exactly: + // + // if (startFlag) { + // startFlag = false; + // decayLevel = 15; + // dividerCounter = volume; // immediate reload + // } else if (dividerCounter == 0) { + // dividerCounter = volume; + // if (decayLevel > 0) { + // --decayLevel; + // } else if (loop) { // loop = lengthCounter->halt + // decayLevel = 15; + // } else { + // silence the channel // v.enabled = false, ADSR off + // } + // } else { + // --dividerCounter; + // } + // output = constVolume ? volume : decayLevel; + // + // The `loop` flag is read from `nesLengthCounter.halt` (the + // shared bit; matches M5's pattern for the linear counter). + for (int slot = 0; slot < MAX_VOICES; ++slot) { + Voice& v = voices[slot]; + if (v.type != WaveType::PULSE && v.type != WaveType::NOISE) continue; + NesEnvelope& env = v.nesEnvelope; + if (!env.envelopeEnabled) continue; + if (env.lengthCounter == nullptr) continue; // safety: pointer must be bound in reset() + const bool loop = env.lengthCounter->halt; // shared with length counter + + if (env.startFlag) { + env.startFlag = false; + env.decayLevel = 15; + env.dividerCounter = env.volume; + } else { + if (env.dividerCounter == 0) { + env.dividerCounter = env.volume; + if (env.decayLevel > 0) { + --env.decayLevel; + } else { + if (loop) { + env.decayLevel = 15; + } else { + // Silence the channel. Matches the M4 / M5 + // pattern: disable the voice, zero the + // ADSR envelope so the per-sample gate sees + // 0 immediately. The triangle voice is + // untouched (it has no envelope). + v.enabled = false; + v.envelope.stage = EnvelopeState::Stage::OFF; + v.envelope.currentLevel = 0.0f; + v.envelope.currentLevelQ15 = 0; + } + } + } else { + --env.dividerCounter; + } + } + + env.output = env.constVolume ? env.volume : env.decayLevel; + } +#if defined(UNIT_TEST) +#if defined(UNIT_TEST) + ++nesQuarterTickCountForTesting_; +#endif +#endif + } + + void ApuCore::tickAllNesLinearCounters() { + // Hito 2 M5: NES linear counter dispatch. + // + // Iterates the voice pool. Voices with `linearEnabled == false` + // (the default for everything except TRIANGLE-in-NES-mode) are + // skipped silently. For the opted-in voices: + // - if `reloadFlag` is set, the counter is loaded from `reloadValue`; + // - else if the counter is > 0, it is decremented; + // - if the shared `halt`/control flag is clear, `reloadFlag` is cleared. + // + // The order matters: reload/decrement FIRST, then clear reloadFlag. + // This matches the canonical nesdev wiki algorithm exactly. + // + // This method is called by the existing `tickNesFrameCounter` + // from M3, on the quarter-frame step. It is NOT called per sample. + for (int slot = 0; slot < MAX_VOICES; ++slot) { + Voice& v = voices[slot]; + NesLinearCounter& lc = v.nesLinearCounter; + if (!lc.linearEnabled) continue; + if (lc.lengthCounter == nullptr) continue; // safety: pointer must be bound in reset() + const bool control = lc.lengthCounter->halt; // shared with length counter + + if (lc.reloadFlag) { + lc.counter = lc.reloadValue; + } else if (lc.counter > 0) { + --lc.counter; + } + if (!control) { + lc.reloadFlag = false; + } + } +#if defined(UNIT_TEST) + ++nesLinearCounterTickCountForTesting_; +#endif + } + + void ApuCore::tickAllNesLengthCounters() { + // Hito 2 M4: NES length counter dispatch. + // + // Iterates the voice pool. SINE/SAW voices keep + // `nesLengthCounter.enabled == false` and are silently skipped. + // NES voices (PULSE/TRIANGLE/NOISE) decrement their counter on + // each half-clock (skipping when `halt` is set or the counter is + // already 0), and silence the voice when the counter hits 0. + // + // Hito 2 M5 surgical fix: TRIANGLE voices are NOT disabled when + // the length counter reaches 0. The M5 per-sample gate + // (lengthOk && linearOk) handles the silence path, and the voice + // stays enabled so it can be re-armed by a fresh $400B write. + // PULSE and NOISE keep the legacy M4 behaviour. + // + // This method is called by the existing `tickNesFrameCounter` + // from M3, on the half-frame step. It is NOT called per sample. + for (int slot = 0; slot < MAX_VOICES; ++slot) { + Voice& v = voices[slot]; + NesLengthCounter& lc = v.nesLengthCounter; + if (!lc.enabled) continue; + if (lc.halt) continue; + if (lc.counter == 0) continue; + --lc.counter; + if (lc.counter == 0) { + if (v.type != WaveType::TRIANGLE) { + // Silence the voice. Do NOT touch remainingSamples + // (M4 is orthogonal to the pre-Hito-2 duration mechanism; + // the caller's plan stays intact, but the voice is + // disabled so it will not produce audio). + v.enabled = false; + v.envelope.stage = EnvelopeState::Stage::OFF; + v.envelope.currentLevel = 0.0f; + v.envelope.currentLevelQ15 = 0; + } + // TRIANGLE: stay enabled; M5 per-sample gate handles silence. + } + } +#if defined(UNIT_TEST) + ++nesHalfTickCountForTesting_; +#endif + } + + // Hito 2 M4: NES length counter public API. + void ApuCore::setVoiceNesLength(int slot, uint8_t lengthIndex, bool halt) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + NesLengthCounter& lc = voices[slot].nesLengthCounter; + // Writes to a disabled NES channel are silently lost (mirrors NES + // hardware: the channel is muted, so length loads are ignored). + if (!lc.enabled) { + return; + } + if (lengthIndex > 31) { + lengthIndex = 31; + } + lc.index = lengthIndex; + lc.counter = nes_apu::kNesLengthLut[lengthIndex]; + lc.halt = halt; + // Hito 2 M7 surgical fix: NES $4003 / $4007 / $400F writes set the + // envelope start flag as a side effect. The flag is consumed by + // the next quarter-clock tick (the M7 `tickAllNesEnvelopes()` + // dispatcher reloads `decayLevel = 15` and the divider from VVVV). + // It is set even if the envelope is not opted in (matches NES + // hardware: the bit is set independently of the envelope enable). + voices[slot].nesEnvelope.startFlag = true; + // Hito 2 M13: the other documented side effect of the same write. + // On $400B (TRIANGLE) the length load raises the linear counter's + // reload flag, consumed by the next quarter-clock in + // `tickAllNesLinearCounters()`. Set unconditionally for the same + // reason as the envelope start flag above: the dispatchers already + // gate on type and on their own opt-in flag, so a voice that is + // not a NES-mode TRIANGLE never observes it. + voices[slot].nesLinearCounter.reloadFlag = true; + } + + void ApuCore::setVoiceNesChannelEnabled(int slot, bool enabled) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + NesLengthCounter& lc = voices[slot].nesLengthCounter; + Voice& v = voices[slot]; + lc.enabled = enabled; + if (enabled) { + // NES: "There is no immediate effect when enabled is set." + return; + } + // Cleared: force the counter to 0 and silence the voice. + lc.counter = 0; + v.enabled = false; + v.envelope.stage = EnvelopeState::Stage::OFF; + v.envelope.currentLevel = 0.0f; + v.envelope.currentLevelQ15 = 0; + // Note: remainingSamples is intentionally NOT touched here. + // M4 is orthogonal to the pre-Hito-2 duration mechanism (§2.4 of + // the M4 design doc). + } + + // Hito 2 M5: NES linear counter public API. + void ApuCore::setVoiceNesLinearCounter(int slot, uint8_t reloadValue, bool reloadFlag) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + NesLinearCounter& lc = voices[slot].nesLinearCounter; + // Mirror the M4 pattern: always store, effect only at the next + // quarter-clock tick. This means writes to a voice that has not + // yet enabled NES mode are kept and acted on later when the voice + // is opted in. + lc.reloadValue = (reloadValue & 0x7F); + lc.reloadFlag = reloadFlag; + } + + void ApuCore::setVoiceNesLinearEnabled(int slot, bool enabled) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + voices[slot].nesLinearCounter.linearEnabled = enabled; + } + + // Hito 2 M7: NES envelope unit public API. + void ApuCore::setVoiceNesEnvelope(int slot, bool loop, bool constVolume, uint8_t volume) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + NesEnvelope& env = voices[slot].nesEnvelope; + env.volume = (volume & 0x0F); + env.constVolume = constVolume; + // The `loop` arg is stored for introspection, but the live read + // at quarter-clock time comes from `nesLengthCounter.halt` + // (shared bit, bound via pointer in AudioChannel::reset()). + // This matches the M5 linear counter pattern. + // We do NOT change `nesLengthCounter.halt` here — that is owned + // by `setVoiceNesLength`. A test that wants the envelope loop + // set must call `setVoiceNesLength(slot, idx, true)` first. + // The "loop" arg on this method is therefore a stored hint + // mirrored into the envelope struct for symmetry with the + // wiki register layout, but the dispatch loop reads `halt`. + (void)loop; + // Mirrors the NES hardware: the envelope write does NOT set the + // start flag. The start flag is set exclusively by the length + // load (`setVoiceNesLength`). + } + + void ApuCore::setVoiceNesEnvelopeEnabled(int slot, bool enabled) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + voices[slot].nesEnvelope.envelopeEnabled = enabled; + } + + void ApuCore::tickAllNesSweepUnits() { + // Hito 2 M6: NES sweep unit dispatch (half-clock consumer). + // + // Iterates the voice pool and ticks the sweep unit of every PULSE + // voice. Non-PULSE voices are skipped (their nesSweepUnit is + // configured by callers but never updated by the dispatcher). + // + // Per https://www.nesdev.org/wiki/APU_Sweep, the half-clock logic is: + // 1. Compute the target period using the CURRENT timer, shift, negate, + // and isPulse2. The target is checked for > 0x7FF BEFORE clamping + // — overflow is the signal for muting, so it must be observed. + // 2. Update the `muted` flag from (current period < 8) OR (target > 0x7FF). + // This is evaluated CONTINUOUSLY, even when the sweep is "disabled" + // (E=0 or SSS=0). That's the "NES bug": the muting logic always runs, + // the timer-update path doesn't. + // 3. If `dividerCounter == 0` and the sweep is enabled, shift > 0, and + // the channel isn't muted, update `timer` from the (clamped) target. + // 4. Reload the divider: if `dividerCounter == 0` OR `reloadFlag` is + // set, `dividerCounter = period + 1`; else decrement. + for (int slot = 0; slot < MAX_VOICES; ++slot) { + Voice& v = voices[slot]; + if (v.type != WaveType::PULSE) continue; + NesSweepUnit& sw = v.nesSweepUnit; + // Opt-in gate. Without it the muting rule below would fire on + // the default `timer == 0` and silence every PULSE voice that + // is still driven by the legacy float frequency path. + if (!sw.unitEnabled) continue; + + // Step 1: compute the raw target period. + const uint16_t period = sw.timer; + const int32_t change = (sw.shift > 0) ? (period >> sw.shift) : 0; + int32_t target; + if (sw.negate) { + target = sw.isPulse2 + ? ((int32_t)period - change) + : ((int32_t)period - change - 1); + } else { + target = (int32_t)period + change; + } + if (target < 0) target = 0; + // NOTE: do NOT clamp target to 0x7FF here — the muting check + // needs the raw value. Clamp only happens when the timer is + // actually updated in step 3. + + // Step 2: update the muting flag (always). + sw.muted = (period < 8) || (target > 0x7FF); + + // Step 3: timer update (only on the divider==0 boundary, with + // the three "sweep is active" guards: E=1, SSS>0, !muted). + if (sw.dividerCounter == 0) { + if (sw.sweepEnabled && sw.shift > 0 && !sw.muted) { + int32_t clamped = (target > 0x7FF) ? 0x7FF : target; + sw.timer = (uint16_t)clamped; + // Re-sync the per-voice phase increments so the hot + // path sees the new frequency immediately (no need + // to wait for the next setVoiceNesTimer call). + syncNesTimerToPhaseIncQ32(v, sampleRate); + } + } + + // Step 4: reload or decrement the divider. + if (sw.dividerCounter == 0 || sw.reloadFlag) { + sw.dividerCounter = sw.period + 1; + sw.reloadFlag = false; + } else { + --sw.dividerCounter; + } + } +#if defined(UNIT_TEST) + ++nesSweepTickCountForTesting_; +#endif + } + + // Hito 2 M6: NES sweep unit public API. + void ApuCore::setVoiceNesSweep(int slot, bool enabled, uint8_t period, bool negate, + uint8_t shift, bool isPulse2) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + NesSweepUnit& sw = voices[slot].nesSweepUnit; + sw.sweepEnabled = enabled; + sw.period = (period & 0x07); + sw.negate = negate; + sw.shift = (shift & 0x07); + sw.isPulse2 = isPulse2; + // $4001 / $4005 write side effect: schedule a divider reload. + sw.reloadFlag = true; + // The muting flag is continuously evaluated (the "NES bug"), + // so any change to E/P/N/SSS must re-trigger the check, even + // before the next half-clock. This matches the spec test + // `test_sweep_muting_target_overflow`: the caller sets the + // timer, then the sweep fields, and expects `muted` to be + // observable immediately. + recomputeSweepMuted(voices[slot]); + } + + void ApuCore::setVoiceNesSweepUnitEnabled(int slot, bool enabled) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + voices[slot].nesSweepUnit.unitEnabled = enabled; + // Re-evaluate immediately in both directions: arming must apply + // the muting rule to the register state already written, and + // disarming must clear `muted` so the per-sample gate stops + // silencing a voice that left NES sweep control. + recomputeSweepMuted(voices[slot]); + } + + void ApuCore::setVoiceNesTimer(int slot, uint16_t timer11) { + if (slot < 0 || slot >= MAX_VOICES) { + return; + } + timer11 &= 0x07FF; + voices[slot].nesSweepUnit.timer = timer11; + // Re-sync the per-voice phase increments so the oscillator + // frequency matches the new NES timer period immediately + // (the hot path does NOT need to wait for the next half-clock). + syncNesTimerToPhaseIncQ32(voices[slot], sampleRate); + // Recompute the muting flag right now so the per-sample + // gate sees the correct value before the next half-clock. + recomputeSweepMuted(voices[slot]); + } + + // Hito 2 M3: per-sample tick of the NES frame counter. + // `apuCyclesPerSample` is computed once per generateSamples() call and + // passed in to keep the inner loop free of division by sampleRate. + void ApuCore::tickNesFrameCounter(double apuCyclesPerSample) { + // Default OFF: the canonical track never enables the frame counter, + // so its PCM must stay byte-for-byte identical (no accumulator + // update, no clock dispatches). + if (!frameCounter_.enabled) { + return; + } + // Apply $4017 write reset side-effect: zero the cycle counter on + // the first sample after the mode was set. + if (frameCounter_.resetPending) { + frameCounter_.apuCycles = 0.0; + frameCounter_.currentStep = 0; + frameCounter_.resetPending = false; + // Per nesdev wiki, $4017 M=1 also fires quarter+half on the reset + // sample. Mode 0 does not (it just resets the timer). + if (frameCounter_.mode == 1) { + tickAllNesEnvelopes(); + tickAllNesLinearCounters(); + tickAllNesLengthCounters(); + tickAllNesSweepUnits(); + } + } + + frameCounter_.apuCycles += apuCyclesPerSample; + + const double* steps = (frameCounter_.mode == 0) + ? nes_apu::kMode0ApuCyclesPerStep + : nes_apu::kMode1ApuCyclesPerStep; + const int maxStep = (frameCounter_.mode == 0) ? 4 : 5; + + // Fire every step the accumulator has crossed (could be > 1 at low + // sample rates). The step fired is the new `currentStep` (1-indexed). + while (frameCounter_.apuCycles >= steps[frameCounter_.currentStep + 1]) { + ++frameCounter_.currentStep; + + const bool isQuarter = (frameCounter_.mode == 0) + ? (frameCounter_.currentStep == 1 + || frameCounter_.currentStep == 2 + || frameCounter_.currentStep == 3) + : (frameCounter_.currentStep == 1 + || frameCounter_.currentStep == 2 + || frameCounter_.currentStep == 3 + || frameCounter_.currentStep == 5); + const bool isHalf = (frameCounter_.mode == 0) + ? (frameCounter_.currentStep == 2 + || frameCounter_.currentStep == 4) + : (frameCounter_.currentStep == 2 + || frameCounter_.currentStep == 4); + + if (isQuarter) { + tickAllNesEnvelopes(); + tickAllNesLinearCounters(); + } + if (isHalf) { + tickAllNesLengthCounters(); + tickAllNesSweepUnits(); + } + + if (frameCounter_.currentStep >= maxStep) { + // Wrap. Subtract the cycle count of the last step so + // `apuCycles` represents time since wrap. The wrap is + // itself a "step 0 entry" of the next cycle: + // - Mode 0: fires one extra quarter + IRQ set. Half was + // already fired at step 4 entry (designed doc, table + // "→ 0" row). This matches the canonical 240/120/60 Hz + // rate: 4 quarters, 2 halves, 1 IRQ per cycle. + // - Mode 1: no clocks on wrap (designed doc, table "→ 0" + // row is empty). Matches 192/~96 Hz rate. + if (frameCounter_.mode == 0) { + tickAllNesEnvelopes(); + tickAllNesLinearCounters(); + if (!frameCounter_.irqInhibit) { + // The visible flag is sticky (no $4015 read + // modeled), but we still flip false→true to + // reflect the actual edge. The test-only counter + // also tracks the *generation* rate (60 Hz) so + // the test can observe the configured cadence + // without the flag oscillating. + if (!frameCounter_.irqFlag) { + frameCounter_.irqFlag = true; + } +#if defined(UNIT_TEST) + ++nesIrqSetCountForTesting_; +#endif + } + } + frameCounter_.apuCycles -= steps[maxStep]; + frameCounter_.currentStep = 0; + } + } + } + +#if defined(UNIT_TEST) + // Test-only accessors resume here. + void ApuCore::resetNesFrameCounterForTesting() { + frameCounter_.apuCycles = 0.0; + frameCounter_.currentStep = 0; + frameCounter_.irqFlag = false; + frameCounter_.resetPending = false; + // mode and irqInhibit are deliberately left untouched. + } + + int ApuCore::getNesFrameCounterQuarterTickCountForTesting() const { + return nesQuarterTickCountForTesting_; + } + + int ApuCore::getNesFrameCounterHalfTickCountForTesting() const { + return nesHalfTickCountForTesting_; + } + + int ApuCore::getNesFrameCounterLinearTickCountForTesting() const { + return nesLinearCounterTickCountForTesting_; + } + + int ApuCore::getNesFrameCounterSweepTickCountForTesting() const { + return nesSweepTickCountForTesting_; + } + + int ApuCore::getNesFrameCounterIrqSetCountForTesting() const { + return nesIrqSetCountForTesting_; + } + + double ApuCore::getNesFrameCounterApuCyclesForTesting() const { + return frameCounter_.apuCycles; + } + + int ApuCore::getNesFrameCounterCurrentStepForTesting() const { + return frameCounter_.currentStep; + } + + // Hito 2 M4: NES length counter test-only accessors. + uint16_t ApuCore::getVoiceNesLengthCounterForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesLengthCounter.counter; + } + + uint8_t ApuCore::getVoiceNesLengthIndexForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesLengthCounter.index; + } + + bool ApuCore::getVoiceNesLengthHaltForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesLengthCounter.halt; + } + + bool ApuCore::getVoiceNesChannelEnabledForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesLengthCounter.enabled; + } + + // Hito 2 M5: NES linear counter test-only accessors. + uint8_t ApuCore::getVoiceNesLinearCounterForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesLinearCounter.counter; + } + + uint8_t ApuCore::getVoiceNesLinearReloadValueForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesLinearCounter.reloadValue; + } + + bool ApuCore::getVoiceNesLinearReloadFlagForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesLinearCounter.reloadFlag; + } + + bool ApuCore::getVoiceNesLinearEnabledForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesLinearCounter.linearEnabled; + } + + // Hito 2 M6: NES sweep unit test-only accessors. + uint16_t ApuCore::getVoiceNesSweepTimerForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesSweepUnit.timer; + } + + uint8_t ApuCore::getVoiceNesSweepDividerForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesSweepUnit.dividerCounter; + } + + bool ApuCore::getVoiceNesSweepEnabledForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesSweepUnit.sweepEnabled; + } + + bool ApuCore::getVoiceNesSweepNegateForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesSweepUnit.negate; + } + + uint8_t ApuCore::getVoiceNesSweepShiftForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesSweepUnit.shift; + } + + bool ApuCore::getVoiceNesSweepUnitEnabledForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesSweepUnit.unitEnabled; + } + + bool ApuCore::getVoiceNesSweepMutedForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesSweepUnit.muted; + } + + // Hito 2 M7: NES envelope unit test-only accessors. + uint8_t ApuCore::getVoiceNesEnvelopeOutputForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesEnvelope.output; + } + + uint8_t ApuCore::getVoiceNesEnvelopeDecayLevelForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesEnvelope.decayLevel; + } + + uint8_t ApuCore::getVoiceNesEnvelopeDividerForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesEnvelope.dividerCounter; + } + + bool ApuCore::getVoiceNesEnvelopeStartFlagForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesEnvelope.startFlag; + } + + bool ApuCore::getVoiceNesEnvelopeEnabledForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesEnvelope.envelopeEnabled; + } + + bool ApuCore::getVoiceNesEnvelopeConstVolumeForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return false; + } + return voices[slot].nesEnvelope.constVolume; + } + + uint8_t ApuCore::getVoiceNesEnvelopeVolumeForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 0u; + } + return voices[slot].nesEnvelope.volume; + } + + uint8_t ApuCore::getVoiceNesNoiseLutIndexForTesting(int slot) const { + if (slot < 0 || slot >= MAX_VOICES) { + return 255u; + } + return voices[slot].nesNoiseLutIndex; + } + + // Hito 4 M10: master HPF test-only accessors. + float ApuCore::getHpfRForTesting() const { return hpfR_; } + int32_t ApuCore::getHpfRQ15ForTesting() const { return hpfRQ15_; } + bool ApuCore::getHpfEnabledForTesting() const { return hpfEnabled_; } + float ApuCore::getHpfCutoffHzForTesting() const { return hpfCutoffHz_; } + + // Hito 4 M11. + SoftClipMode ApuCore::getSoftClipModeForTesting() const { return softClipMode_; } #endif void ApuCore::clearMusicTrackVoiceState() { @@ -735,6 +1743,26 @@ namespace pixelroot32::audio { } break; + case AudioCommandType::SET_NES_OPTIONS: + // Bounds-checked inside the setter, but keep the guard + // here too so the intent is visible next to the others. + if (cmd.channelIndex < MAX_VOICES) { + setVoiceNesOptions(cmd.channelIndex, cmd.nesOptions); + } + break; + + case AudioCommandType::TRIGGER_NES_LENGTH: + if (cmd.channelIndex < MAX_VOICES) { + // Arm the channel first: `setVoiceNesLength` drops + // writes to a disabled NES channel, and the enable + // setter is not reachable from the game thread, so + // without this the queued path could never fire. + setVoiceNesChannelEnabled(cmd.channelIndex, true); + setVoiceNesLength(cmd.channelIndex, cmd.nesLengthIndex, + cmd.nesLengthHalt); + } + break; + case AudioCommandType::MUSIC_PLAY: { activeTrackCount = 1; tracks[0] = cmd.track; @@ -1321,11 +2349,22 @@ namespace pixelroot32::audio { if (noiseHz < 1.0f) noiseHz = 1000.0f; period = noise_clock_hz_to_period(noiseHz, sampleRate); } + // Hito 3 M8: an armed NOISE LUT index wins over both the direct + // `noisePeriod` and the frequency-derived period. Like M2's duty + // mode this is a per-slot setting, so it survives new notes. + if (ch->nesNoiseLutIndex < 16u) { + period = nes_apu::nesNoisePeriodToSamples(ch->nesNoiseLutIndex, + sampleRate); + } ch->frequency = noiseHz; ch->noisePeriodSamples = period; ch->noiseCountdown = 1u; - ch->lfsrState = 0x4000; - ch->noiseShortMode = (event.preset) ? event.preset->noiseShortMode : false; + // Hito 1 M9: NES-equivalent seed (all 1s in the 15-bit LFSR) — + // matches the state after a write to register $400F. + ch->lfsrState = 0x7FFF; + const bool lfsrShort = (event.preset) ? event.preset->noiseLfsrShort : false; + ch->noiseLfsrShort = lfsrShort; + ch->noiseShortMode = lfsrShort; // keep deprecated alias in sync ch->phase = 0.0f; ch->phaseIncrement = 0.0f; ch->dutySweep = 0.0f; @@ -1355,6 +2394,24 @@ namespace pixelroot32::audio { ch->automationAgeSamples = 0; } + // Hito 1 M16: NES TRIANGLE is one octave below PULSE for the same + // period. When the application opts in, double the effective + // frequency and rebuild both the float and Q32 phase increments so + // the inner-loop arithmetic sees the doubled rate immediately. + if (event.type == WaveType::TRIANGLE && ch->triangleOctaveUp) { + ch->frequency *= 2.0f; + ch->phaseIncrement = ch->frequency / (float)sampleRate; + if (sampleRate > 0) { + const double inc = (double)ch->frequency * 4294967296.0 / (double)sampleRate; + ch->phaseIncQ32 = (inc < 0.0) ? 0u + : (inc >= 4294967295.0) ? 0xFFFFFFFFu + : (uint32_t)inc; + } else { + ch->phaseIncQ32 = 0u; + } + ch->basePhaseIncQ32 = ch->phaseIncQ32; + } + init_pitch_envelope(*ch, event, sampleRate); ch->sweepSamplesTotal = 0; @@ -1419,6 +2476,13 @@ namespace pixelroot32::audio { } } } + + // Hito 2 M17: last, so no earlier branch can leave stale NES state + // behind. Off unless the preset opts in, which keeps the default + // render path and the canonical golden PCM untouched. + if (event.preset && event.preset->nesAccurate) { + initNesSafe(*ch, event.type); + } } void ApuCore::playSequencerPercussionHit(const AudioEvent& event) { @@ -1655,15 +2719,65 @@ namespace pixelroot32::audio { float sample = 0.0f; switch (ch.type) { - case WaveType::PULSE: - sample = (ch.phase < ch.dutyCycle) ? 1.0f : -1.0f; + case WaveType::PULSE: { + if (ch.pulseDutyIndex < 4) { + // Hito 1 M2: NES discrete duty (12.5/25/50/75%). + // phaseQ32 top 3 bits = current 8-step index (matches Q15 path). + const uint32_t step = (ch.phaseQ32 >> 29) & 7u; + sample = kNesPulseDuty[ch.pulseDutyIndex][step] ? 1.0f : -1.0f; + } else { + sample = (ch.phase < ch.dutyCycle) ? 1.0f : -1.0f; + } + // Hito 2 M6: NES sweep muting. The `muted` flag is + // continuously evaluated by the half-clock dispatch + // (and by `setVoiceNesTimer`), even when the sweep is + // "disabled" (E=0 or SSS=0). When set, the PULSE output + // is forced to 0 (the phase still advances below). + if (ch.nesSweepUnit.muted) { + sample = 0.0f; + } break; - case WaveType::TRIANGLE: - // TODO: future NES-accurate 4-bit quantization (deferred) - sample = (ch.phase < 0.5f) - ? (4.0f * ch.phase - 1.0f) - : (3.0f - 4.0f * ch.phase); + } + case WaveType::TRIANGLE: { + // Hito 2 M5: per-sample NES gate. The TRIANGLE channel is + // ANDed by (length counter open) AND (linear counter open). + // Either sub-unit, when its respective `enabled` flag is + // false, is treated as always-open. When the gate is + // closed the output is 0 and the phase does NOT advance — + // re-loading via a $4008 / $400B write resumes the voice. + const bool lengthOk = + (!ch.nesLengthCounter.enabled) || (ch.nesLengthCounter.counter > 0); + const bool linearOk = + (!ch.nesLinearCounter.linearEnabled) || (ch.nesLinearCounter.counter > 0); + const bool gate = lengthOk && linearOk; + + if (gate) { + float tri = (ch.phase < 0.5f) + ? (4.0f * ch.phase - 1.0f) + : (3.0f - 4.0f * ch.phase); + if (ch.triangleQuantize4Bit) { + // Hito 1 M1: NES 4-bit quantisation. + // 32 levels = 16 positive + 16 negative, step = 2/15, range + // [-1, +1]. The duty counter's 5-bit value 0..15 maps to + // 2*counter/15 with the counter not reaching "0" (apex + // duplicates at phase 0 and 0.5). We snap to the nearest + // of {-1, -13/15, -11/15, ..., 11/15, 13/15, 1}. + const float step = 2.0f / 15.0f; + const int idx = (int)std::floor((tri + 1.0f) / step + 0.5f); + const int clamped = (idx < 0) ? 0 : (idx > 15 ? 15 : idx); + tri = ((float)clamped * step) - 1.0f; + } + sample = tri; + ch.phase += ch.phaseIncrement; + if (ch.phase >= 1.0f) ch.phase -= 1.0f; + ch.phaseQ32 += ch.phaseIncQ32; + } else { + // Gate closed: silent, no phase advance. Voice stays + // enabled so it can resume when a counter reloads. + sample = 0.0f; + } break; + } case WaveType::SINE: { const unsigned i = (unsigned)(ch.phase * 256.0f) & 255u; sample = (float)SINE_LUT_Q15[i] / 32768.0f; @@ -1677,7 +2791,7 @@ namespace pixelroot32::audio { // timbre is identical in simulator and hardware. if (ch.noiseCountdown > 0u) ch.noiseCountdown--; if (ch.noiseCountdown == 0u) { - const uint16_t bitToXor = ch.noiseShortMode ? ((ch.lfsrState >> 6) & 1u) : ((ch.lfsrState >> 1) & 1u); + const uint16_t bitToXor = ch.noiseLfsrShort ? ((ch.lfsrState >> 6) & 1u) : ((ch.lfsrState >> 1) & 1u); const uint16_t fb = (uint16_t)(((ch.lfsrState & 1u) ^ bitToXor) & 1u); ch.lfsrState = (uint16_t)((ch.lfsrState >> 1) | (fb << 14)); ch.noiseCountdown = ch.noisePeriodSamples; @@ -1690,10 +2804,13 @@ namespace pixelroot32::audio { break; } - if (ch.type != WaveType::NOISE) { + if (ch.type != WaveType::NOISE && ch.type != WaveType::TRIANGLE) { ch.phase += ch.phaseIncrement; if (ch.phase >= 1.0f) ch.phase -= 1.0f; - + // Hito 1 M2: keep phaseQ32 in sync with the float phase on the + // FPU path so the NES discrete duty lookup (which uses the + // top 3 bits of phaseQ32) tracks the same step as the Q15 path. + ch.phaseQ32 += ch.phaseIncQ32; if (ch.type == WaveType::PULSE && ch.dutySweep != 0.0f) { ch.dutyCycle += ch.dutySweep; if (ch.dutyCycle > 1.0f) ch.dutyCycle -= 1.0f; @@ -1737,7 +2854,20 @@ namespace pixelroot32::audio { } } - return sample * ch.volume * ch.envelope.currentLevel * lfoVolMod; + // Hito 2 M7: when the NES envelope is enabled for this voice + // (PULSE / NOISE only), the envelope's `output` (0..15) replaces + // the ADSR `envelope.currentLevel` as the per-sample volume + // source. The NES envelope's `output` is the const-volume level + // (VVVV) or the current decay level, both 0..15. Scaling by + // 1/15.0 maps output=15 to 1.0 (output=0 → 0.0). Matches NES. + float envLevel; + if ((ch.type == WaveType::PULSE || ch.type == WaveType::NOISE) + && ch.nesEnvelope.envelopeEnabled) { + envLevel = (float)ch.nesEnvelope.output / 15.0f; + } else { + envLevel = ch.envelope.currentLevel; + } + return sample * ch.volume * envLevel * lfoVolMod; } // ------------------------------------------------------------------ @@ -1750,16 +2880,25 @@ namespace pixelroot32::audio { updateMusicSequencer(); constexpr float FINAL_SCALE = 32767.0f; - // HPF coefficient: y[n] = x[n] - x[n-1] + R*y[n-1] - // R = 0.995 at 22050 Hz -> ~35 Hz -3dB - constexpr float HPF_R = 0.995f; + // HPF is `y[n] = x[n] - x[n-1] + R*y[n-1]`; R now lives in `hpfR_` + // (Hito 4 M10) so the corner can be configured in Hz. #if PR32_APU_HAS_FPU // ---- Float path (native, ESP32 classic, ESP32-S3) --------------- // Single float branch for every FPU-capable or desktop target; the // integer/LUT branch below is reserved for no-FPU cores (and for // native builds that force -DPR32_APU_HAS_FPU=0 in tests). + + // Hito 2 M3: APU cycles per render sample. Computed once per + // generateSamples() call. When the frame counter is OFF (default) + // the tick is a no-op, so this is just a cheap set-up cost. + const double apuCyclesPerSample = + (sampleRate > 0) ? (nes_apu::kNesApuHzNtsc / (double)sampleRate) : 0.0; + for (int i = 0; i < length; ++i) { + // Hito 2 M3: tick the NES frame counter before voice mixing so + // any future sub-units (M4-M7) see a consistent clock state. + tickNesFrameCounter(apuCyclesPerSample); float acc = 0.0f; for (int c = 0; c < MAX_VOICES; ++c) { if (voices[c].enabled) { @@ -1767,12 +2906,43 @@ namespace pixelroot32::audio { } } acc *= masterVolume; - float mixed = acc / (1.0f + std::fabs(acc) * MIXER_K); + // Hito 4 M11: master shaping curve, applied before the DC + // blocker so the filter sees whatever the curve produced. + float mixed; + switch (softClipMode_) { + case SoftClipMode::None: + mixed = acc; + break; + case SoftClipMode::Tanh: { + // Rational tanh approximation: x*(27+x^2)/(27+9x^2). + const float x2 = acc * acc; + mixed = acc * (27.0f + x2) / (27.0f + 9.0f * x2); + break; + } + case SoftClipMode::HardClip: + // The NES DAC saturates rather than compressing. + mixed = (acc > 1.0f) ? 1.0f : ((acc < -1.0f) ? -1.0f : acc); + break; + case SoftClipMode::Rational: + default: + mixed = acc / (1.0f + std::fabs(acc) * MIXER_K); + break; + } // DC-blocker: removes duty-asymmetry DC and softens retrigger pops. - const float hpfOut = mixed - hpfPrevIn + HPF_R * hpfPrevOut; - hpfPrevIn = mixed; - hpfPrevOut = hpfOut; + // Hito 4 M10: coefficient comes from the configured corner. + float hpfOut; + if (hpfEnabled_) { + hpfOut = mixed - hpfPrevIn + hpfR_ * hpfPrevOut; + hpfPrevIn = mixed; + hpfPrevOut = hpfOut; + } else { + hpfOut = mixed; + // Keep the state coherent so re-enabling starts the + // recursion cleanly instead of stepping from a stale value. + hpfPrevIn = mixed; + hpfPrevOut = 0.0f; + } float finalSample = hpfOut * FINAL_SCALE; const float absSample = std::fabs(finalSample); @@ -1801,14 +2971,35 @@ namespace pixelroot32::audio { volQ15[c] = (int32_t)(voices[c].volume * 32768.0f); } + // Hito 2 M3: APU cycles per render sample (same as FPU path). + const double apuCyclesPerSample = + (sampleRate > 0) ? (nes_apu::kNesApuHzNtsc / (double)sampleRate) : 0.0; + // Hoisted wave type dispatch - compute sample once before inner loop // using inline branch-free operations instead of switch auto generatePulseSampleQ15 = [](AudioChannel& ch) -> int32_t { + if (ch.pulseDutyIndex < 4) { + const uint32_t step = (ch.phaseQ32 >> 29) & 7u; + return kNesPulseDuty[ch.pulseDutyIndex][step] ? 32767 : -32767; + } return (ch.phaseQ32 < ch.dutyCycleQ32) ? 32767 : -32767; }; auto generateTriangleSampleQ15 = [](AudioChannel& ch) -> int32_t { const uint32_t p16 = ch.phaseQ32 >> 16; - return (p16 < 32768u) ? ((int32_t)(p16 * 2) - 32768) : (32768 - (int32_t)((p16 - 32768u) * 2)); + int32_t tri_q15 = (p16 < 32768u) + ? ((int32_t)(p16 << 1) - 32768) + : (32768 - (int32_t)((p16 - 32768u) << 1)); + if (ch.triangleQuantize4Bit) { + // NES 4-bit quantisation (same as the file-scope lambda). + constexpr int32_t kStep = (2 * 32768) / 15; + constexpr int32_t kHalf = kStep / 2; + int32_t shifted = tri_q15 + 32768 + kHalf; + int32_t idx = shifted / kStep; + if (idx < 0) idx = 0; + if (idx > 15) idx = 15; + tri_q15 = idx * kStep - 32768; + } + return tri_q15; }; auto generateNoiseSampleQ15 = [](AudioChannel& ch) -> int32_t { return (ch.lfsrState & 1u) ? 32767 : -32767; @@ -1817,6 +3008,8 @@ namespace pixelroot32::audio { // Process channels by type - hoisted from inner loop // This eliminates the switch(ch.type) branch misprediction for (int i = 0; i < length; ++i) { + // Hito 2 M3: tick the NES frame counter before voice mixing. + tickNesFrameCounter(apuCyclesPerSample); int32_t sum = 0; for (int c = 0; c < MAX_VOICES; ++c) { @@ -1830,7 +3023,7 @@ namespace pixelroot32::audio { // NOISE: inline state update (required for LFSR mutation) if (ch.noiseCountdown > 0u) ch.noiseCountdown--; if (ch.noiseCountdown == 0u) { - const uint16_t bitToXor = ch.noiseShortMode ? ((ch.lfsrState >> 6) & 1u) : ((ch.lfsrState >> 1) & 1u); + const uint16_t bitToXor = ch.noiseLfsrShort ? ((ch.lfsrState >> 6) & 1u) : ((ch.lfsrState >> 1) & 1u); const uint16_t fb = (uint16_t)(((ch.lfsrState & 1u) ^ bitToXor) & 1u); ch.lfsrState = (uint16_t)((ch.lfsrState >> 1) | (fb << 14)); ch.noiseCountdown = ch.noisePeriodSamples; @@ -1839,8 +3032,35 @@ namespace pixelroot32::audio { } else if (ch.type == WaveType::SINE) { // SINE: direct LUT lookup s = (int32_t)SINE_LUT_Q15[(ch.phaseQ32 >> 24) & 255u]; + } else if (ch.type == WaveType::TRIANGLE) { + // Hito 2 M5: per-sample NES gate. AND of (length open) + // and (linear open); each sub-unit, when its `enabled` + // flag is false, is treated as always-open. When the + // gate is closed the output is 0 and the phase does + // NOT advance — re-loading via a $4008 / $400B write + // resumes the voice. + const bool lengthOk = + (!ch.nesLengthCounter.enabled) || (ch.nesLengthCounter.counter > 0); + const bool linearOk = + (!ch.nesLinearCounter.linearEnabled) || (ch.nesLinearCounter.counter > 0); + if (lengthOk && linearOk) { + s = generateTriangleSampleQ15(ch); + ch.phaseQ32 += ch.phaseIncQ32; // advance only if gate open + } else { + s = 0; + } + } else if (ch.type == WaveType::PULSE) { + // Hito 2 M6: PULSE special case to apply the sweep + // muting flag (always-evaluated by the half-clock + // dispatch). SAW + SINE go through the function + // pointer dispatch below. + s = generatePulseSampleQ15(ch); + if (ch.nesSweepUnit.muted) { + s = 0; + } } else if (auto gen = WAVE_GENERATORS_Q15[static_cast(ch.type)]) { - // PULSE, TRIANGLE, SAW: function pointer lookup + // SAW: function pointer lookup. (TRIANGLE handled above, + // PULSE handled above, NOISE / SINE handled above.) s = gen(ch); } else { // Fallback (should never reach) @@ -1848,8 +3068,18 @@ namespace pixelroot32::audio { } // Apply per-channel Q15 volume × envelope: (s * volQ15 * envQ15) >> 30 + // Hito 2 M7: when the NES envelope is enabled (PULSE / NOISE), + // use the envelope's `output` (0..15) scaled to Q15 as the + // multiplier. 32768/15 = 2184, so output=15 → 32760 (≈1.0). + int32_t envQ15; + if ((ch.type == WaveType::PULSE || ch.type == WaveType::NOISE) + && ch.nesEnvelope.envelopeEnabled) { + envQ15 = (int32_t)ch.nesEnvelope.output * 2184; // 32768/15 + } else { + envQ15 = ch.envelope.currentLevelQ15; + } int32_t sv = (s * volQ15[c]) >> 15; - sv = (sv * ch.envelope.currentLevelQ15) >> 15; + sv = (sv * envQ15) >> 15; // Volume LFO (tremolo) — Q15 only, no float conversion. if (ch.lfo.enabled && ch.lfo.target == LfoTarget::VOLUME) { @@ -1859,17 +3089,23 @@ namespace pixelroot32::audio { sv = (sv * volModQ15) >> 15; } - // MIXER_SCALE = 0.4 per channel. 0.4 ≈ 13107/32768 so we - // scale by 13107 and shift 15 to land back in Q15. This - // matches the FPU branch exactly (MIXER_SCALE * channel). - sv = (sv * 13107) >> 15; + // Hito 5 M12: NO per-channel MIXER_SCALE here. The + // accumulator carries the RAW channel sum (+-131072 for four + // full-scale voices) because that is the convention every + // downstream stage was built for: `audio_mixer_lut` bakes the + // 0.4 into its own mapping, and the computed curves apply it + // once explicitly. Scaling here as well applied 0.4 twice and + // left the outer 60% of the table unreachable. sum += sv; // Duty stepped + pitch envelope (shared age; integer pitch path). tick_voice_automation(ch, sampleRate, /*q15Path=*/true); // Phase accumulator (integer, wraps automatically). - if (ch.type != WaveType::NOISE) { + // Hito 2 M5: TRIANGLE phase is advanced inside the gate + // branch above (only when the gate is open), so we must + // skip it here to avoid double-advancing. + if (ch.type != WaveType::NOISE && ch.type != WaveType::TRIANGLE) { ch.phaseQ32 += ch.phaseIncQ32; if (ch.type == WaveType::PULSE && ch.dutySweepQ32 != 0) { ch.dutyCycleQ32 += ch.dutySweepQ32; // naturally wraps @@ -1962,37 +3198,84 @@ namespace pixelroot32::audio { sum = (int32_t)(((int64_t)sum * (int64_t)masterVolumeScale) >> 16); } - int32_t index = (sum + 131072) >> 8; - if (index < 0) index = 0; - if (index > 1024) index = 1024; - - // Q15 HPF coefficient: R = 0.995, Q15 = 0.995 * 32768 = 32604 - // Yields ~35 Hz -3dB cutoff at 22050 Hz sample rate - static constexpr int32_t HPF_R_Q15 = 32604; + // Hito 4 M11: master shaping curve. `Rational` keeps the + // pre-fitted LUT so the canonical golden PCM is untouched; the + // other modes are computed from the same accumulated mix. + // + // The LUT maps the RAW sum through `32767 * S / (1 + |S|*0.5)` + // with `S = sum * 1.6 / 131072`, i.e. it applies MIXER_SCALE = 0.4 + // itself (four full-scale channels land at S = 1.6, the float + // path's post-MIXER_SCALE bound). The computed modes therefore + // need the same single 0.4 applied by hand: `(sum * 13107) >> 15`. + int32_t inputQ15; + if (softClipMode_ == SoftClipMode::Rational) { + int32_t index = (sum + 131072) >> 8; + if (index < 0) index = 0; + if (index > 1024) index = 1024; + inputQ15 = audio_mixer_lut[index]; + } else { + const int32_t sQ15 = (int32_t)(((int64_t)sum * 13107) >> 15); + switch (softClipMode_) { + case SoftClipMode::Tanh: { + // x*(27+x^2)/(27+9x^2) evaluated in Q15. int64 is + // required: |sQ15| reaches ~52428 when four voices + // sum at full scale, so x^2 overflows int32. + const int64_t x2 = ((int64_t)sQ15 * sQ15) >> 15; + const int64_t num = (int64_t)27 * 32768 + x2; + const int64_t den = (int64_t)27 * 32768 + 9 * x2; + inputQ15 = (int32_t)(((int64_t)sQ15 * num) / den); + break; + } + case SoftClipMode::HardClip: + inputQ15 = sQ15; + if (inputQ15 > 32767) inputQ15 = 32767; + if (inputQ15 < -32768) inputQ15 = -32768; + break; + case SoftClipMode::None: + default: + // Unshaped: the final int16 clamp is the only bound, + // so the HPF still sees the overshoot (as in float). + inputQ15 = sQ15; + break; + } + } // Q15 HPF: y[n] = x[n] - x[n-1] + (R * y[n-1]) - // Uses Q15 fixed-point to avoid soft-float on RISC-V cores - int32_t inputQ15 = audio_mixer_lut[index]; - - // Compute R * y[n-1] in Q30, then shift to Q15 - int64_t feedback = (int64_t)HPF_R_Q15 * (int64_t)hpfPrevOutQ15; - int32_t feedbackQ15 = (int32_t)(feedback >> 15); - - // HPF difference equation - int32_t hpfOutQ15 = inputQ15 - hpfPrevInQ15 + feedbackQ15; - - // Saturating clamp to prevent overflow artifacts - if (hpfOutQ15 > 32767) hpfOutQ15 = 32767; - if (hpfOutQ15 < -32768) hpfOutQ15 = -32768; - - // Update state for next sample - hpfPrevInQ15 = inputQ15; - hpfPrevOutQ15 = hpfOutQ15; + // Uses Q15 fixed-point to avoid soft-float on RISC-V cores. + // Hito 4 M10: `hpfRQ15_` mirrors the float coefficient, derived + // from the configured corner. Default 32604 = 0.995 * 32768, + // the historical value (~35.2 Hz at 44100, ~17.6 Hz at 22050). + int32_t hpfOutQ15; + if (hpfEnabled_) { + // Compute R * y[n-1] in Q30, then shift to Q15 + int64_t feedback = (int64_t)hpfRQ15_ * (int64_t)hpfPrevOutQ15; + int32_t feedbackQ15 = (int32_t)(feedback >> 15); + + // HPF difference equation + hpfOutQ15 = inputQ15 - hpfPrevInQ15 + feedbackQ15; + + // Saturating clamp to prevent overflow artifacts + if (hpfOutQ15 > 32767) hpfOutQ15 = 32767; + if (hpfOutQ15 < -32768) hpfOutQ15 = -32768; + + // Update state for next sample + hpfPrevInQ15 = inputQ15; + hpfPrevOutQ15 = hpfOutQ15; + } else { + hpfOutQ15 = inputQ15; + hpfPrevInQ15 = inputQ15; + hpfPrevOutQ15 = 0; + } - // Fixed -6 dB headroom pad (Q15 -> Q14). NOTE: the FPU path has no - // equivalent pad; kept for now to avoid a silent loudness change on - // no-FPU targets — revisit when unifying paths in the APU library. - int32_t finalSample = hpfOutQ15 >> 1; + // Hito 5 M12: the fixed -6 dB headroom pad (`>>1`, Q15 -> Q14) is + // gone; this path now lands at the same level as the float one. + // The pad also happened to keep the value below half scale, so the + // int16 cast could never overflow. It can now: an unshaped mix + // reaches S = 1.6 and a bypassed HPF skips the clamp above, so + // saturate here exactly as the float branch does before its cast. + int32_t finalSample = hpfOutQ15; + if (finalSample > 32767) finalSample = 32767; + if (finalSample < -32768) finalSample = -32768; stream[i] = apply_master_bitcrush((int16_t)finalSample, masterBitcrushBits_); @@ -2038,6 +3321,47 @@ namespace pixelroot32::audio { postMixUser_ = user; } + void ApuCore::setVoicePulseDutyMode(int slot, uint8_t nesDutyIndex) { + if (slot < 0 || slot >= MAX_VOICES) return; + // 0..3 -> NES discrete patterns. 4..254 are reserved / invalid and + // are clamped to continuous. 255 is the documented "continuous" + // sentinel and is stored verbatim. + if (nesDutyIndex < 4 || nesDutyIndex == 255) { + voices[slot].pulseDutyIndex = nesDutyIndex; + } else { + voices[slot].pulseDutyIndex = 255; + } + } + + // Hito 3 M8: NES NOISE period LUT. + void ApuCore::setVoiceNesNoiseLutMode(int slot, uint8_t lutIndex) { + if (slot < 0 || slot >= MAX_VOICES) return; + Voice& v = voices[slot]; + // 0..15 -> LUT entry. 16..254 are invalid and fall back to the + // documented 255 "derive from frequency" sentinel. + v.nesNoiseLutIndex = (lutIndex < 16u) ? lutIndex : 255u; + + // Retune now so the change is audible without waiting for a new + // note, matching how setVoiceNesTimer re-syncs the pulse period. + if (v.type != WaveType::NOISE) return; + if (v.nesNoiseLutIndex < 16u) { + v.noisePeriodSamples = + nes_apu::nesNoisePeriodToSamples(v.nesNoiseLutIndex, sampleRate); + } else { + v.noisePeriodSamples = noise_clock_hz_to_period(v.frequency, sampleRate); + } + if (v.noiseCountdown > v.noisePeriodSamples) { + v.noiseCountdown = v.noisePeriodSamples; + } + } + +#if defined(UNIT_TEST) + void ApuCore::setVoiceTriangleOctaveUpForTesting(int slot, bool enabled) { + if (slot < 0 || slot >= MAX_VOICES) return; + voices[slot].triangleOctaveUp = enabled; + } +#endif + void ApuCore::getAndResetProfileStats(ProfileEntry* out, uint8_t& count) { count = profileCount; uint8_t startIdx = profileCount > 0 ? (profileWriteIdx.load(std::memory_order_relaxed) - profileCount + PROFILE_RING_SIZE) % PROFILE_RING_SIZE : 0; diff --git a/test/golden/fixtures/apu_mirror_canonical_s16le_44100.pcm b/test/golden/fixtures/apu_mirror_canonical_s16le_44100.pcm index 4397fa1..1ee3fc6 100644 Binary files a/test/golden/fixtures/apu_mirror_canonical_s16le_44100.pcm and b/test/golden/fixtures/apu_mirror_canonical_s16le_44100.pcm differ diff --git a/test/test_apu_core.cpp b/test/test_apu_core.cpp index 5be753e..9af6495 100644 --- a/test/test_apu_core.cpp +++ b/test/test_apu_core.cpp @@ -20,6 +20,7 @@ #include #include "test_config.h" #include "pixelroot32/apu/ApuCore.h" +#include "pixelroot32/apu/ApuConfig.h" #include "pixelroot32/apu/AudioTypes.h" #include "pixelroot32/apu/AudioMusicTypes.h" @@ -1246,7 +1247,8 @@ void test_apu_core_melodic_zero_release_leaves_gate_unchanged(void) { 0.0f, // lfoFrequency 0.0f, // lfoDepth 0.0f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort + false, // noiseShortMode (deprecated alias) 0.0f // dutySweep }; @@ -2109,6 +2111,3399 @@ void test_apu_core_music_uses_track_duty_not_preset_duty(void) "track duty 0.125 must dominate over preset 0.5"); } +// ============================================================================= +// Hito 1 M2: NES discrete PULSE duty (4 patterns) +// ============================================================================= + +// Verifies each of the 4 NES duty indices produces the expected 8-step +// pattern when reading back through a freshly-init voice. Uses a low sample +// rate to keep the frequency reachable and trigger the voice deterministically. +void test_pulse_duty_nes_4modes(void) +{ + // Expected patterns (1 = high, 0 = low). Mirrors the canonical + // nesdev.org/wiki/APU_Pulse table. + static const uint8_t kExpected[4][8] = { + {1,0,0,0,0,0,0,0}, // 12.5% + {0,1,1,0,0,0,0,0}, // 25% + {0,1,1,1,1,0,0,0}, // 50% + {1,0,1,1,1,1,1,0}, // 75% + }; + + for (uint8_t duty = 0; duty < 4; ++duty) { + ApuCore apu; + apu.init(44100); + apu.reset(); + // SFX subpool starts at MUSIC_VOICE_BASE + MUSIC_VOICE_COUNT (= 4). + constexpr int kSfxBase = ApuCore::MUSIC_VOICE_BASE + ApuCore::MUSIC_VOICE_COUNT; + apu.setVoicePulseDutyMode(kSfxBase, duty); + + // 8 samples per cycle at exactly 5512.5 Hz. After 88 samples (the + // default 2 ms attack ramp at 44100 Hz) the envelope is at full + // and the phase happens to be at a cycle boundary (88 % 8 == 0). + // Reading from sample 88 yields a clean 8-step window. + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 5512.5f; + cmd.event.duration = 0.05f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.5f; // ignored when pulseDutyIndex<4 + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[128] = {}; + apu.generateSamples(buffer, 128); + + // Cycle boundary at sample 88 (attack ramp end). Verify all 8 steps + // of the duty pattern. The 12.5% pattern has the high step at + // index 0; the 25% / 50% have low step 0; 75% has high step 0. + for (int step_idx = 0; step_idx < 8; ++step_idx) { + const int16_t v = buffer[88 + step_idx]; + const int16_t expected_v = kExpected[duty][step_idx] ? 1 : -1; + const int sign = (v > 0) ? 1 : (v < 0 ? -1 : 0); + const int expected_sign = expected_v; + TEST_ASSERT_EQUAL_INT(expected_sign, sign); + } + } +} + +// With pulseDutyIndex == 255 the generator must fall back to the legacy +// continuous dutyCycle comparison. 50% duty should produce a balanced output. +void test_pulse_duty_continuous_unchanged(void) +{ + ApuCore apu; + apu.init(44100); + apu.reset(); + // Default (after reset) is 255 -> continuous, which is the legacy path. + // We exercise it by submitting a play event with a 50% duty and + // verifying the positive/negative split is roughly balanced. + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 220.0f; + cmd.event.duration = 0.5f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.5f; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[8192] = {}; + apu.generateSamples(buffer, 8192); + + int positive = 0; + int negative = 0; + for (int i = 0; i < 8192; ++i) { + if (buffer[i] > 0) ++positive; + else if (buffer[i] < 0) ++negative; + } + // At 50% duty, the positive/negative split must be close (within 10 %). + const int total = positive + negative; + TEST_ASSERT_TRUE(total > 0); + const float ratio = static_cast(positive) / static_cast(total); + TEST_ASSERT_TRUE_MESSAGE(ratio > 0.40f && ratio < 0.60f, + "continuous 50% duty must remain near 50/50"); +} + +// ============================================================================= +// Hito 1 M16: TRIANGLE octave doubling (opt-in) +// ============================================================================= + +// With triangleOctaveUp == false (the default), the voice frequency is +// preserved at the event value. We verify the public frequency readout. +void test_triangle_octave_up_default_off(void) +{ + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.1f; + cmd.event.volume = 1.0f; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[16] = {}; + apu.generateSamples(buffer, 16); + + constexpr int kSfxBase = ApuCore::MUSIC_VOICE_BASE + ApuCore::MUSIC_VOICE_COUNT; + TEST_ASSERT_FLOAT_WITHIN(0.001f, 440.0f, + apu.getVoiceFrequencyForTesting(kSfxBase)); +} + +// With triangleOctaveUp == true on the target voice at init time, the +// effective frequency is doubled (440 -> 880). The test uses the +// test-only setter to flip the flag on the SFX base slot before +// submitting the play event, then asserts the public frequency readout. +void test_triangle_octave_up_doubles_freq(void) +{ + ApuCore apu; + apu.init(44100); + apu.reset(); + + constexpr int kSfxBase = ApuCore::MUSIC_VOICE_BASE + ApuCore::MUSIC_VOICE_COUNT; + apu.setVoiceTriangleOctaveUpForTesting(kSfxBase, true); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.1f; + cmd.event.volume = 1.0f; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[16] = {}; + apu.generateSamples(buffer, 16); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 880.0f, + apu.getVoiceFrequencyForTesting(kSfxBase)); +} + +// Documents that the octave-up doubling is gated on TRIANGLE only. PULSE +// voices must not be affected by the flag, which we verify by submitting +// a PULSE event with the flag set and confirming the frequency stays at +// 440 Hz. +void test_triangle_octave_up_ignored_for_other_types(void) +{ + ApuCore apu; + apu.init(44100); + apu.reset(); + + constexpr int kSfxBase = ApuCore::MUSIC_VOICE_BASE + ApuCore::MUSIC_VOICE_COUNT; + apu.setVoiceTriangleOctaveUpForTesting(kSfxBase, true); + + AudioCommand pulse{}; + pulse.type = AudioCommandType::PLAY_EVENT; + pulse.event.type = WaveType::PULSE; + pulse.event.frequency = 440.0f; + pulse.event.duration = 0.1f; + pulse.event.volume = 1.0f; + pulse.event.duty = 0.5f; + TEST_ASSERT_TRUE(apu.submitCommand(pulse)); + + int16_t buf[16] = {}; + apu.generateSamples(buf, 16); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 440.0f, + apu.getVoiceFrequencyForTesting(kSfxBase)); +} + +// ============================================================================= +// Hito 2 M7: NES envelope unit (PULSE / NOISE) +// ============================================================================= +// +// All envelope tests are isolated per `ApuCore` instance. The frame +// counter is OFF by default, so we explicitly enable mode 0 when we need +// quarter-frame ticks to advance. The M3 test-only counter +// `getNesFrameCounterQuarterTickCountForTesting()` lets us advance by +// quarter-clock count rather than computing quarter-clocks-per-sample. + +// Dispatches a continuous looping PULSE event and returns the SFX slot +// it landed on. Tests that assert on `isVoiceEnabledForTesting` need a +// real voice: arming only the NES sub-units leaves the slot disabled, +// which would make a "the envelope did not silence it" assertion pass +// or fail for the wrong reason. `scratch` must hold at least 256 +// samples. Mirrors the pattern used by the M4 length-counter tests. +static int startLoopingPulseVoice(ApuCore& apu, int16_t* scratch) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; // 0 + loop => continuous + cmd.event.volume = 0.5f; + cmd.event.duty = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + apu.generateSamples(scratch, 256); // dispatch the play event + + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + return s; + } + } + TEST_FAIL_MESSAGE("looping PULSE event did not land on an SFX slot"); + return -1; +} + +// Renders until `count` more quarter-frame clocks have fired. Requires +// the frame counter to be running (`setNesFrameCounterMode`). +static void advanceQuarterClocks(ApuCore& apu, int16_t* scratch, int count) { + const int target = apu.getNesFrameCounterQuarterTickCountForTesting() + count; + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < target + && safety < 1000) { + apu.generateSamples(scratch, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT( + target, apu.getNesFrameCounterQuarterTickCountForTesting()); +} + +// Renders `length` samples into `scratch` and returns the peak absolute +// magnitude. Amplitude probe for tests that compare relative levels. +static int32_t renderPeakMagnitude(ApuCore& apu, int16_t* scratch, int length) { + apu.generateSamples(scratch, length); + int32_t peak = 0; + for (int i = 0; i < length; ++i) { + const int32_t mag = scratch[i] < 0 ? -(int32_t)scratch[i] : scratch[i]; + if (mag > peak) peak = mag; + } + return peak; +} + +// After init+reset, every voice's `nesEnvelope` must be in the default +// state: envelopeEnabled=false, output=0, decayLevel=0, dividerCounter=0, +// startFlag=false, constVolume=false, volume=0. The shared +// `lengthCounter` pointer must be non-null (bound in +// AudioChannel::reset()). This is the "default OFF" invariant that +// keeps the canonical track PCM byte-for-byte identical. +void test_envelope_initial_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + for (int slot = 0; slot < ApuCore::MAX_VOICES; ++slot) { + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeOutputForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDecayLevelForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDividerForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeConstVolumeForTesting(slot)); + } + // The lengthCounter pointer binding is exercised by every other + // envelope test in this file; we just confirm the field is + // reachable via the test accessors. +} + +// Enable the envelope on slot 0 with V=8. Load length index 0 (which +// also sets the start flag via the M4 surgical fix). After 1 +// quarter-clock: start flag cleared, decayLevel=15, dividerCounter=8. +// Subsequent quarter-clocks decrement the divider (8ΓåÆ7ΓåÆΓǪΓåÆ0). On the +// quarter-clock AFTER the divider reaches 0, decayLevel decrements to +// 14 and the divider reloads to 8. +void test_envelope_setup_and_decay(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); // also sets start flag + apu.setVoiceNesEnvelope(0, false, false, 8); // loop=off, C=off, V=8 + apu.setVoiceNesEnvelopeEnabled(0, true); + + // Pre-Q-clock: start flag is set (M4 surgical fix). divider is 0 + // (default ΓÇö the start flag loads the divider on the next Q-clock). + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + + int16_t buffer[64] = {0}; + + // Quarter-clock 1: start flag ΓåÆ decayLevel=15, dividerCounter=8. + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(15, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesEnvelopeDividerForTesting(0)); + + // Quarter-clocks 2..9: divider decrements 8ΓåÆ7ΓåÆΓǪΓåÆ0. After 9 total + // quarter-clocks the divider is 0 (it took 8 decrements from 8 to + // reach 0 on the 9th tick). decayLevel still 15. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 9 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDividerForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(15, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); + + // Quarter-clock 10: divider was 0 ΓåÆ reload to 8, decayLevel 15ΓåÆ14. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 10 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(14, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesEnvelopeDividerForTesting(0)); +} + +// With loop=ON (via length counter halt), the decay level must wrap +// from 0 back to 15 every time the divider underflows, instead of +// silencing the channel. The voice must stay enabled across the wrap. +void test_envelope_loop(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // A continuous PULSE voice, so `enabled` reflects the envelope's + // decision rather than "this slot was never armed". + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 0, true); // halt=true -> loop=ON + apu.setVoiceNesEnvelope(slot, true, false, 1); // V=1, fast decay + apu.setVoiceNesEnvelopeEnabled(slot, true); + + // Run enough quarter-clocks to bring decayLevel to 0 and back + // around the loop several times. V=1 means the divider reloads to + // 1 each time, so each decrement takes 2 quarter-clocks. From + // decayLevel=15 to 0 takes 15├ù2=30 quarter-clocks. Add 32 more + // quarter-clocks to ensure we wrap twice. + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 60 + && safety < 5000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(60, + apu.getNesFrameCounterQuarterTickCountForTesting()); + + // The voice must still be enabled (loop prevents silence). + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(slot)); + // decayLevel must be in the legal range (0..15) - it wrapped at + // least once and is now somewhere mid-cycle. + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeDecayLevelForTesting(slot) <= 15); +} + +// With constant volume (C=1), the output must be VVVV (=10) immediately, +// regardless of the decay level. The decay level still updates per the +// wiki (we verify it has decremented at least once), but the output +// stays at V. +void test_envelope_const_volume(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + apu.setVoiceNesEnvelope(0, false, true, 10); // C=ON, V=10 + apu.setVoiceNesEnvelopeEnabled(0, true); + + // Render 1 sample. After 1 quarter-clock: start flag consumed, + // output = volume = 10. + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(10, apu.getVoiceNesEnvelopeOutputForTesting(0)); + + // Render 11 more quarter-clocks (enough for at least one divider + // wrap with V=10 ΓåÆ ~10 ticks to decrement once). Output must stay + // at 10 because C=1 ignores the decay level. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 12 + && safety < 5000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(10, apu.getVoiceNesEnvelopeOutputForTesting(0)); +} + +// With V=0 (the divider reloads to 0 each time) and !loop, the +// decayLevel must decrement EVERY quarter-clock, and when it reaches +// 0 the channel must be silenced. +void test_envelope_silence_at_zero(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + apu.setVoiceNesEnvelope(0, false, false, 0); // V=0, divider counts at 1/Q + apu.setVoiceNesEnvelopeEnabled(0, true); + + int16_t buffer[64] = {0}; + + // Quarter-clock 1: start ΓåÆ decayLevel=15, divider=0. + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(15, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDividerForTesting(0)); + + // Quarter-clock 2: divider was 0 ΓåÆ reload to 0, decayLevel 15ΓåÆ14. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(14, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDividerForTesting(0)); + + // Run 16 more quarter-clocks to bring decayLevel to 0. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 17 + && safety < 5000) { + apu.generateSamples(buffer, 64); + ++safety; + } + // decayLevel at 0; the channel must be silenced on the 17th + // quarter-clock (decayLevel was 1 ΓåÆ 0, then on the 18th it would + // hit the !loop silence path; with 17 ticks total we just hit 0). + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeOutputForTesting(0)); +} + +// The start flag (set by setVoiceNesLength) must be cleared on the +// first quarter-clock after the load, and the decayLevel must be +// reloaded to 15. This is the test for the M4 surgical fix to +// setVoiceNesLength. +void test_envelope_start_flag_cleared_after_first_qclock(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); // sets start flag (M7 fix) + apu.setVoiceNesEnvelope(0, false, false, 8); + apu.setVoiceNesEnvelopeEnabled(0, true); + + // Pre-tick: start flag is set. + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + + // Post-tick: start flag cleared, decayLevel=15. + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(15, apu.getVoiceNesEnvelopeDecayLevelForTesting(0)); +} + +// TRIANGLE / SINE / SAW voices must keep `envelopeEnabled = false` +// permanently and the quarter-clock dispatch must skip them. The +// sample output must remain driven by the ADSR (default path). +void test_envelope_does_not_affect_sine_saw_triangle(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // Try to enable the envelope on a TRIANGLE / SINE / SAW voice. + // The quarter-clock dispatch must skip them. + apu.setVoiceNesEnvelopeEnabled(2, true); // (some slot) + apu.setVoiceNesEnvelope(2, true, false, 15); + + // Render a few thousand samples (more than a few quarter-clocks). + // Sized for the largest single render below (256 samples). + int16_t buffer[256] = {0}; + for (int i = 0; i < 100; ++i) { + apu.generateSamples(buffer, 64); + } + + // decayLevel stays at 0 and output stays at 0 ΓÇö the dispatch + // skipped slot 2 because either its type is non-PULSE/NOISE or + // its `envelopeEnabled` was never explicitly enabled. + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDecayLevelForTesting(2)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeOutputForTesting(2)); + + // Now exercise the "PULSE+noise skipped" path via type filtering: + // enable a TRIANGLE voice for real and verify the dispatch loop + // does not touch its envelope (even after a real quarter-clock + // tick). We use slot 1 (after dispatching a triangle). + AudioCommand tri{}; + tri.type = AudioCommandType::PLAY_EVENT; + tri.event.type = WaveType::TRIANGLE; + tri.event.frequency = 440.0f; + tri.event.duration = 0.0f; // + loop = continuous + tri.event.volume = 0.5f; + tri.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(tri)); + apu.generateSamples(buffer, 256); + + // Enable the envelope on this triangle slot. The dispatch must + // still skip it (type != PULSE/NOISE). + int triSlot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + triSlot = s; + break; + } + } + if (triSlot >= 0) { + apu.setVoiceNesEnvelopeEnabled(triSlot, true); + apu.setVoiceNesEnvelope(triSlot, true, false, 15); + // Run a few thousand more samples and confirm the envelope + // state stays untouched. + const uint8_t decayBefore = + apu.getVoiceNesEnvelopeDecayLevelForTesting(triSlot); + for (int i = 0; i < 200; ++i) { + apu.generateSamples(buffer, 64); + } + TEST_ASSERT_EQUAL_UINT8(decayBefore, + apu.getVoiceNesEnvelopeDecayLevelForTesting(triSlot)); + } +} + +// With constant volume selected, the envelope output is V, and the +// mixed signal must scale linearly with V/15. We compare the peak +// magnitude over a full render window at V=15 against the same window +// at V=7 and expect the ratio 7/15. Peak-over-window rather than a +// single sample: the output high-pass filter turns the flat parts of a +// pulse wave into a decaying tail, so one arbitrary sample is not a +// stable amplitude probe, while the peak is. +void test_envelope_volume_scaled_correctly(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // A continuous PULSE voice, so the channel stays enabled across the + // render windows below. + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // Enable the NES channel + envelope. + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 0, true); // halt=true: length must not silence + apu.setVoiceNesEnvelope(slot, false, true, 15); // C=1, V=15 -> output 1.0 + apu.setVoiceNesEnvelopeEnabled(slot, true); + + // The envelope output register only updates on a quarter-frame + // clock, so a freshly enabled envelope reads 0 until the first one + // lands. Advance past it before probing the amplitude. + advanceQuarterClocks(apu, buffer, 1); + + // 256 samples is ~5.8 periods at 440 Hz, so the window always + // contains several edges and therefore the signal peak. + const int32_t peak15 = renderPeakMagnitude(apu, buffer, 256); + TEST_ASSERT_TRUE_MESSAGE(peak15 > 4000, + "V=15 must produce near-full output (envelope at 1.0)"); + + // V=7 -> output 7 -> 7/15 of the amplitude. The Q15 path uses + // 32768/15 = 2184 per level (7 -> 15288 vs 15 -> 32760), so the + // ratio is 7/15 on both the float and the integer path. + apu.setVoiceNesEnvelope(slot, false, true, 7); + advanceQuarterClocks(apu, buffer, 1); + const int32_t peak7 = renderPeakMagnitude(apu, buffer, 256); + + const float ratio = (float)peak7 / (float)peak15; + TEST_ASSERT_FLOAT_WITHIN(0.05f, 7.0f / 15.0f, ratio); +} + +// Enable BOTH the length counter (M4) and the envelope (M7). The +// length counter reaches 0 first (LUT[3]=2 ΓåÆ 2 half-clocks) and +// silences the channel; the envelope would also have silenced +// eventually but the length counter wins because it counts faster +// (every half-clock vs every quarter-clock's wrap). +void test_envelope_coexists_with_length_counter(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 3, false); // LUT[3] = 2 ΓåÆ 2 half-clocks + apu.setVoiceNesEnvelope(0, false, false, 0); // V=0, fast decay + apu.setVoiceNesEnvelopeEnabled(0, true); + + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, + apu.getNesFrameCounterHalfTickCountForTesting()); + + // Length counter reached 0 ΓåÆ channel silenced. + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(0)); +} + +// The envelope's loop flag is SHARED with `nesLengthCounter.halt`. +// Setting halt=true via setVoiceNesLength must make the envelope loop, +// even if `setVoiceNesEnvelope` was called with loop=false. +void test_envelope_loop_flag_shared_with_length_halt(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 0, true); // halt=true -> loop=ON + apu.setVoiceNesEnvelopeEnabled(slot, true); + // loop=false here on purpose: the live loop bit comes from the + // length counter's `halt`, not from this argument. + apu.setVoiceNesEnvelope(slot, false, false, 1); // V=1, fast decay + + // Run enough quarter-clocks to bring decayLevel to 0 several times. + // V=1: divider=1, so each decrement takes 2 quarter-clocks. 15 + // decrements ΓåÆ ~30 quarter-clocks to hit 0, then wrap to 15. + // We run 60 quarter-clocks to ensure at least 2 wraps. + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 60 + && safety < 5000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(60, + apu.getNesFrameCounterQuarterTickCountForTesting()); + + // Loop=ON: voice stays enabled, decayLevel cycles 0..15 (no silence). + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(slot)); + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeDecayLevelForTesting(slot) <= 15); +} + +// ============================================================================= +// Hito 2 M3: NES frame counter +// ============================================================================= +// Hito 2 M3: NES frame counter +// ============================================================================= +// +// The frame counter is OFF by default. Each test enables it explicitly via +// `setNesFrameCounterMode(0 or 1)` and counts the per-tick dispatchers that +// the stub methods increment when `UNIT_TEST` is defined. + +// Mode 0 (4-step): quarter Γëê 240 Hz, half Γëê 120 Hz, IRQ Γëê 60 Hz. +void test_frame_counter_initial_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + // Default OFF: IRQ is false; no ticks have fired. + TEST_ASSERT_FALSE(apu.getNesFrameCounterIrq()); + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterQuarterTickCountForTesting()); + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterIrqSetCountForTesting()); + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterCurrentStepForTesting()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, + (float)apu.getNesFrameCounterApuCyclesForTesting()); +} + +void test_frame_counter_mode_set_resets_timer(void) { + ApuCore apu; + apu.init(44100); + + // Accumulate a non-zero cycle count by rendering samples in the default + // (mode 0, resetPending false on first call) state. Even though the + // counter is "mode 0" in the default-init sense, it is OFF until + // setNesFrameCounterMode is called and the tick is gated on mode != + // default, so the accumulator stays at 0.0 here. + int16_t buffer[256] = {0}; + apu.generateSamples(buffer, 256); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, + (float)apu.getNesFrameCounterApuCyclesForTesting()); + + // Enable mode 1: the setter must schedule a reset (the actual reset + // happens on the next tick, so verify state right after the call). + apu.setNesFrameCounterMode(1); + // The setter zeros the IRQ flag and marks resetPending. The cycle + // accumulator is still whatever it was (0.0 here). + TEST_ASSERT_FALSE(apu.getNesFrameCounterIrq()); + + // Render a single sample: the first sample after mode-set should + // observe the reset, then advance currentStep appropriately. + apu.generateSamples(buffer, 1); + // After reset + first sample: currentStep is whatever the accumulator + // indicates (likely still 0, since 20.29 APU cycles is well below + // 3728.5 step 1). Just confirm the cycle counter is non-zero AND + // the state is sane (mode preserved, IRQ still false in mode 1). + TEST_ASSERT_FALSE(apu.getNesFrameCounterIrq()); + // Mode 1 has no IRQ -- confirm the counter is still 0. + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterIrqSetCountForTesting()); +} + +void test_frame_counter_quarter_clock_fires_at_240hz_mode0(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // Render 22050 samples (= 0.5 s @ 44100 Hz). Expected quarter rate + // is 240 Hz -> ~120 ticks in 0.5 s. + int16_t buffer[2048] = {0}; + int total = 0; + while (total < 22050) { + const int n = (22050 - total > 2048) ? 2048 : (22050 - total); + apu.generateSamples(buffer, n); + total += n; + } + + const int quarters = apu.getNesFrameCounterQuarterTickCountForTesting(); + // Accept ┬▒1 ticks of rounding slack. + TEST_ASSERT_INT_WITHIN(1, 120, quarters); +} + +void test_frame_counter_half_clock_fires_at_120hz_mode0(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[2048] = {0}; + int total = 0; + while (total < 22050) { + const int n = (22050 - total > 2048) ? 2048 : (22050 - total); + apu.generateSamples(buffer, n); + total += n; + } + + const int halves = apu.getNesFrameCounterHalfTickCountForTesting(); + TEST_ASSERT_INT_WITHIN(1, 60, halves); +} + +void test_frame_counter_mode1_no_irq(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(1); + + int16_t buffer[2048] = {0}; + int total = 0; + while (total < 44100) { + const int n = (44100 - total > 2048) ? 2048 : (44100 - total); + apu.generateSamples(buffer, n); + total += n; + } + + // Mode 1 never sets the IRQ flag. + TEST_ASSERT_FALSE(apu.getNesFrameCounterIrq()); + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterIrqSetCountForTesting()); +} + +void test_frame_counter_irq_fires_at_60hz_mode0(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[2048] = {0}; + int total = 0; + while (total < 44100) { + const int n = (44100 - total > 2048) ? 2048 : (44100 - total); + apu.generateSamples(buffer, n); + total += n; + } + + // 1 s @ 60 Hz -> ~60 IRQ sets, ┬▒1. + const int irqSets = apu.getNesFrameCounterIrqSetCountForTesting(); + TEST_ASSERT_INT_WITHIN(1, 60, irqSets); + TEST_ASSERT_TRUE(apu.getNesFrameCounterIrq()); +} + +void test_frame_counter_irq_inhibit_prevents_irq(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + apu.setNesFrameCounterInhibit(true); + + int16_t buffer[2048] = {0}; + int total = 0; + while (total < 44100) { + const int n = (44100 - total > 2048) ? 2048 : (44100 - total); + apu.generateSamples(buffer, n); + total += n; + } + + TEST_ASSERT_EQUAL_INT(0, apu.getNesFrameCounterIrqSetCountForTesting()); + TEST_ASSERT_FALSE(apu.getNesFrameCounterIrq()); +} + +void test_frame_counter_step_timing_uses_cpu_cycles(void) { + // At 22050 Hz, 0.5 s = 11025 samples. If the counter used sample + // counts, it would fire ~60 quarters in 0.5 s; using APU cycles it + // must fire ~120 (the rate is independent of the audio sample rate). + ApuCore apu; + apu.init(22050); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[1024] = {0}; + int total = 0; + while (total < 11025) { + const int n = (11025 - total > 1024) ? 1024 : (11025 - total); + apu.generateSamples(buffer, n); + total += n; + } + + const int quarters = apu.getNesFrameCounterQuarterTickCountForTesting(); + // ~120 quarters at 240 Hz in 0.5 s; the test fails loudly if the + // counter is sample-driven (would give ~60). + TEST_ASSERT_INT_WITHIN(2, 120, quarters); +} + +// ============================================================================= +// Hito 2 M4: NES length counter +// ============================================================================= +// +// All length counter tests are isolated per `ApuCore` instance. The +// frame counter is OFF by default, so we explicitly enable mode 0 when +// we need half-frame ticks to advance. The M3 test-only counter +// `getNesFrameCounterHalfTickCountForTesting()` lets us advance by +// half-clock count rather than computing half-clocks-per-sample. + +// After init+reset, every voice's `nesLengthCounter` must be in the +// default state: enabled=false, counter=0, index=0, halt=false. This is +// the "default OFF" invariant that keeps the canonical track PCM +// byte-for-byte identical. +void test_length_counter_initial_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + for (int slot = 0; slot < ApuCore::MAX_VOICES; ++slot) { + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLengthIndexForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLengthHaltForTesting(slot)); + } +} + +// After enabling the channel and loading length index 0, the counter +// must be `kNesLengthLut[0] = 10`. Index and halt must reflect the load. +void test_length_counter_load(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLengthIndexForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesLengthHaltForTesting(0)); + + // Loading with halt=true sets the halt flag without changing the + // counter (mirrors the NES "halt doesn't affect the load itself"). + apu.setVoiceNesLength(0, 4, true); + TEST_ASSERT_EQUAL_UINT16(40, apu.getVoiceNesLengthCounterForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(4, apu.getVoiceNesLengthIndexForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLengthHaltForTesting(0)); + + // Out-of-range lengthIndex is clamped to 31. + apu.setVoiceNesLength(0, 255, false); + TEST_ASSERT_EQUAL_UINT8(31, apu.getVoiceNesLengthIndexForTesting(0)); + TEST_ASSERT_EQUAL_UINT16(30, apu.getVoiceNesLengthCounterForTesting(0)); + + // Out-of-range slot is a silent no-op (slot 8 is past the end). + const uint16_t counterBefore = apu.getVoiceNesLengthCounterForTesting(0); + apu.setVoiceNesLength(8, 0, false); + apu.setVoiceNesChannelEnabled(8, true); + TEST_ASSERT_EQUAL_UINT16(counterBefore, apu.getVoiceNesLengthCounterForTesting(0)); +} + +// With the frame counter enabled, the length counter must decrement +// once per half-clock. We render samples until the half-tick counter +// reports 6 half-clocks, then verify counter went from 10 to 4. +void test_length_counter_decrements_on_half_clock(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(0)); + + // Render samples in small chunks until the half-tick counter has + // advanced by 6. At 44100 Hz a half-clock is ~367 samples, so 6 + // half-clocks Γëê 2200 samples; chunked rendering gives the inner + // loop a chance to dispatch each half-clock. + int16_t buffer[256] = {0}; + const int targetHalfTicks = 6; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < targetHalfTicks + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(targetHalfTicks, + apu.getNesFrameCounterHalfTickCountForTesting()); + + // 10 initial - 6 half-ticks = 4. + TEST_ASSERT_EQUAL_UINT16(4, apu.getVoiceNesLengthCounterForTesting(0)); +} + +// With the frame counter enabled and length index 3 (LUT[3] = 2), the +// counter must reach 0 after 2 half-clocks and the voice must be +// silenced. This verifies the "silence the voice when counter becomes +// 0" behaviour that the M4 spec calls out. +void test_length_counter_silences_at_zero(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 3, false); // LUT[3] = 2 + TEST_ASSERT_EQUAL_UINT16(2, apu.getVoiceNesLengthCounterForTesting(0)); + + int16_t buffer[256] = {0}; + const int targetHalfTicks = 2; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < targetHalfTicks + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(targetHalfTicks, + apu.getNesFrameCounterHalfTickCountForTesting()); + + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(0)); + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(0)); +} + +// With `halt` set, the length counter must NOT decrement on half-clocks. +// This matches the NES behaviour: halt blocks the decrement path while +// the channel is still armed. We start a long loop voice so that the +// test can verify both the counter and the voice stay armed. +void test_length_counter_halt_prevents_decrement(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // Long loop voice so the audio thread keeps it enabled. + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; // 0 + loop => continuous + cmd.event.volume = 0.5f; + cmd.event.duty = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[256] = {0}; + apu.generateSamples(buffer, 256); // dispatch the play event + + // Find the SFX slot the voice landed on. + int slot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + slot = s; + break; + } + } + TEST_ASSERT_TRUE(slot >= 0); + + // Arm the length counter with halt=true. + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 0, true); // halt = true + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(slot)); + + // Render ~0.5 s (much more than the 8 ms it would take to fully + // decrement without halt). Counter must stay at 10 throughout, and + // the voice must still be enabled (the length counter never silenced + // it). + int total = 0; + while (total < 22050) { + apu.generateSamples(buffer, 256); + total += 256; + } + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(slot)); + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(slot)); +} + +// Defensive test: the 32 LUT values must match the nesdev wiki table +// byte-for-byte. A typo in `kNesLengthLut` would silently make every +// NES-mode voice play for the wrong duration; this test catches that +// at build time (static_assert) and at runtime. +void test_length_counter_lut_values(void) { + // The 32 canonical values from + // https://www.nesdev.org/wiki/APU_Length_Counter + static constexpr uint8_t kExpected[32] = { + 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, + 12, 16, 24, 18, 48, 20, 96, 22, 192, 24, 72, 26, 16, 28, 32, 30 + }; + // Compile-time check on a few canonical indices. + static_assert(nes_apu::kNesLengthLut[0] == 10, "LUT[0] must be 10"); + static_assert(nes_apu::kNesLengthLut[1] == 254, "LUT[1] must be 254"); + static_assert(nes_apu::kNesLengthLut[3] == 2, "LUT[3] must be 2"); + static_assert(nes_apu::kNesLengthLut[4] == 40, "LUT[4] must be 40"); + static_assert(nes_apu::kNesLengthLut[8] == 160, "LUT[8] must be 160"); + static_assert(nes_apu::kNesLengthLut[31] == 30, "LUT[31] must be 30"); + + // Full 32-value runtime check. + for (int i = 0; i < 32; ++i) { + TEST_ASSERT_EQUAL_UINT8(kExpected[i], nes_apu::kNesLengthLut[i]); + } +} + +// Clearing the channel enable must silence the voice immediately and +// force the length counter to 0. This mirrors the NES $4015 write +// semantics. Critically, the half-clock tick must NOT try to silence +// the voice again afterwards (idempotency). +void test_length_counter_channel_enable_off_forces_silence(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(0)); + + // Clearing the channel enable silences immediately. + apu.setVoiceNesChannelEnabled(0, false); + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(0)); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(0)); + + // Re-enable and load; the previous forced zero must NOT linger. + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 0, false); + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(0)); + // The voice was disabled by the previous clear, so it stays + // disabled; loading the length counter does not re-enable the + // voice (only `setVoiceNesChannelEnabled` or a play event does). + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(0)); +} + +// SINE/SAW voices must keep `nesLengthCounter.enabled == false` +// permanently. The half-clock dispatch loop must skip them even when +// the frame counter is active, and a SINE/SAW voice must continue to +// produce audio (the length counter never silences it). We use +// `loop = true` so the voice stays armed across the full 1 s of +// half-clocks the test renders. +void test_length_counter_does_not_affect_sine_saw(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // Load a SINE voice via the standard PLAY_EVENT path. A long fixed + // duration (no loop) keeps the voice armed across the half-clock + // render window. The SINE voice's `nesLengthCounter.enabled` stays + // false (default), so the half-clock dispatch path skips it. + AudioCommand sineCmd{}; + sineCmd.type = AudioCommandType::PLAY_EVENT; + sineCmd.event.type = WaveType::SINE; + sineCmd.event.frequency = 440.0f; + sineCmd.event.duration = 2.0f; // 2 s, well past the 1 s render window + sineCmd.event.volume = 0.5f; + sineCmd.event.loop = false; + TEST_ASSERT_TRUE(apu.submitCommand(sineCmd)); + int16_t buffer[256] = {0}; + apu.generateSamples(buffer, 256); // dispatch + 256 render samples + + // Find the SINE voice slot in the SFX subpool. + int sineSlot = -1; + for (int slot = ApuCore::SFX_VOICE_BASE; slot < ApuCore::MAX_VOICES; ++slot) { + if (apu.isVoiceEnabledForTesting(slot)) { + sineSlot = slot; + break; + } + } + TEST_ASSERT_TRUE_MESSAGE(sineSlot >= 0, "SINE voice must be present in SFX pool"); + + // Render 1 s of audio (~120 half-clocks @ mode 0). The voice must + // stay armed ΓÇö the half-clock dispatch path skips SINE voices + // because their `nesLengthCounter.enabled` is false. + int total = 0; + while (total < 44100) { + apu.generateSamples(buffer, 256); + total += 256; + } + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(sineSlot)); + // The length counter is still in its default state for SINE/SAW. + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(sineSlot)); +} + +// ============================================================================= +// Hito 2 M5: NES linear counter (TRIANGLE) +// ============================================================================= +// +// All linear counter tests are isolated per `ApuCore` instance. The +// frame counter is OFF by default, so we explicitly enable mode 0 when +// we need quarter-frame ticks to advance. The M3 test-only counter +// `getNesFrameCounterQuarterTickCountForTesting()` lets us advance by +// quarter-clock count rather than computing quarter-clocks-per-sample. + +// After init+reset, every voice's `nesLinearCounter` must be in the +// default state: linearEnabled=false, counter=0, reloadValue=0, +// reloadFlag=false. The shared lengthCounter pointer must be non-null +// (bound in AudioChannel::reset()). This is the "default OFF" invariant +// that keeps the canonical track PCM byte-for-byte identical. +void test_linear_counter_initial_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + for (int slot = 0; slot < ApuCore::MAX_VOICES; ++slot) { + TEST_ASSERT_FALSE(apu.getVoiceNesLinearEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearReloadValueForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearReloadFlagForTesting(slot)); + // The shared lengthCounter pointer is bound by AudioChannel::reset(). + // We can't read the pointer directly, but we can verify the cross-bind + // is in place: the next linear-counter tick must observe the same + // halt flag as the length counter (shared control bit). + // Here we just confirm the field is reachable via the accessors; the + // pointer binding is exercised by the decrement / hold tests below. + } +} + +// Enable the length counter and the linear counter for slot 0, then load +// reloadValue=100 with reloadFlag=true. The counter is NOT reloaded yet ΓÇö +// the load happens on the next quarter-clock. After 1 quarter-clock the +// counter must be 100. +void test_linear_counter_load(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // Enable NES channel (length) and linear. halt=false so reloadFlag + // will be cleared at the next quarter-clock (verified in test 5). + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLinearEnabled(0, true); + apu.setVoiceNesLinearCounter(0, 100, true); + + // Values are stored immediately, but the counter itself has not been + // reloaded yet (that happens on the next quarter-clock). + TEST_ASSERT_EQUAL_UINT8(100, apu.getVoiceNesLinearReloadValueForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(0)); + + // Render samples until the quarter-tick counter has advanced by 1. + // At 44100 Hz a quarter-clock is ~184 samples; we render in 64-sample + // chunks to let the inner loop dispatch each quarter-clock. + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, + apu.getNesFrameCounterQuarterTickCountForTesting()); + + // Quarter-clock fired: reload happened. counter = reloadValue = 100. + TEST_ASSERT_EQUAL_UINT8(100, apu.getVoiceNesLinearCounterForTesting(0)); +} + +// With reloadFlag=true and halt=false (control=0), the counter must +// reload on the first quarter-clock, then decrement on subsequent +// quarter-clocks. After 1 quarter-clock: counter=10 (reloaded) and +// reloadFlag=false (cleared because control=0). After 3 total +// quarter-clocks: counter=8 (reloaded once, decremented twice). +void test_linear_counter_decrements_quarter_clock(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLinearEnabled(0, true); + apu.setVoiceNesLinearCounter(0, 10, true); + + int16_t buffer[64] = {0}; + int safety = 0; + + // Quarter-clock 1: reload ΓåÆ counter=10, reloadFlag cleared (control=0). + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, + apu.getNesFrameCounterQuarterTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(10, apu.getVoiceNesLinearCounterForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + + // Quarter-clocks 2 + 3: decrement twice ΓåÆ counter=8. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 3 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(3, + apu.getNesFrameCounterQuarterTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesLinearCounterForTesting(0)); +} + +// With reloadFlag=true and halt=true (control=1), the counter must +// reload on every quarter-clock and reloadFlag must NEVER be cleared. +// After 5 quarter-clocks: counter=10 (reloaded 5 times), reloadFlag=true. +void test_linear_counter_hold_with_control_set(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLinearEnabled(0, true); + // halt=true (control=1) ΓåÆ reloadFlag is never cleared. + apu.setVoiceNesLength(0, 0, true); + apu.setVoiceNesLinearCounter(0, 10, true); + + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 5 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(5, + apu.getNesFrameCounterQuarterTickCountForTesting()); + + // Control=1 keeps the reload flag set forever (matches the wiki: + // "if !control: reloadFlag = false"). Counter reloads to 10 every + // quarter-clock. + TEST_ASSERT_TRUE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(10, apu.getVoiceNesLinearCounterForTesting(0)); +} + +// With reloadFlag=true and halt=false (control=0), the counter must +// reload on the first quarter-clock and reloadFlag must be cleared. +// After 1 quarter-clock: counter=10, reloadFlag=false. After 3 total +// quarter-clocks: counter=8. +void test_linear_counter_reload_flag_cleared_without_control(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLinearEnabled(0, true); + apu.setVoiceNesLinearCounter(0, 10, true); // halt=false (default) + + int16_t buffer[64] = {0}; + int safety = 0; + + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, + apu.getNesFrameCounterQuarterTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(10, apu.getVoiceNesLinearCounterForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + + // Two more quarter-clocks: counter decrements to 8. + safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 3 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesLinearCounterForTesting(0)); +} + +// Safety-net test: with default NES flags (linearEnabled=false, +// lengthEnabled=false), a TRIANGLE voice must produce non-zero audio. +// This confirms the M5 default-OFF invariant does not break the +// canonical triangle synthesis. +void test_triangle_gate_open_default(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; // 0 + loop = continuous + cmd.event.volume = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[1024] = {0}; + apu.generateSamples(buffer, 1024); + + // At least one sample must be non-zero (the triangle produces a + // non-trivial waveform; with a 4-bit quantiser the smallest non-zero + // magnitude is 2/15 Γëê 0.133, well above 0). + bool anyNonZero = false; + int16_t maxAbs = 0; + for (int i = 0; i < 1024; ++i) { + const int16_t a = (buffer[i] < 0) ? (int16_t)-buffer[i] : buffer[i]; + if (a > 0) anyNonZero = true; + if (a > maxAbs) maxAbs = a; + } + TEST_ASSERT_TRUE_MESSAGE(anyNonZero, + "TRIANGLE default-OFF must still produce audio"); + // The 4-bit quantiser caps the magnitude at 32767 (1.0 ├ù 32767). + // We just sanity-check that the level is meaningful (well above 1 LSB). + TEST_ASSERT_GREATER_THAN_INT16(0, maxAbs); +} + +// TRIANGLE voice with NES length counter enabled, load length index 3 +// (LUT[3]=2). After 2 half-clocks the length counter hits 0. The voice +// must stay enabled (M5 surgical fix) but the output must be silent +// (M5 per-sample gate closes because the length counter is 0). +void test_triangle_gate_closes_when_length_zero(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[256] = {0}; + apu.generateSamples(buffer, 256); // dispatch + 256 render samples + + // Find the slot the TRIANGLE voice landed on. + int slot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + slot = s; + break; + } + } + TEST_ASSERT_TRUE_MESSAGE(slot >= 0, "TRIANGLE voice must be present in SFX pool"); + + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 3, false); // LUT[3] = 2 + TEST_ASSERT_EQUAL_UINT16(2, apu.getVoiceNesLengthCounterForTesting(slot)); + + // Render until 2 half-clocks have fired. + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, + apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(slot)); + + // M5 surgical fix: the voice is NOT disabled when the length counter + // hits 0 (only M5's per-sample gate handles the silence path). + TEST_ASSERT_TRUE_MESSAGE(apu.isVoiceEnabledForTesting(slot), + "TRIANGLE must stay enabled when length counter hits 0 (M5 surgical fix)"); + + // Render a long window and check the bulk of samples. The first few + // samples have a DC-blocker transient (state from prior render), but + // after ~1000 samples at HPF_R=0.995 the residual is < 1% of the + // active-triangle level. An active triangle at volume 0.5 produces + // magnitudes in the thousands; the gate-closed output should be < 50. + int16_t gateBuffer[4096] = {0}; + apu.generateSamples(gateBuffer, 4096); + int16_t maxAbs = 0; + int16_t maxAbsLast2k = 0; + for (int i = 0; i < 4096; ++i) { + const int16_t a = (gateBuffer[i] < 0) ? (int16_t)-gateBuffer[i] : gateBuffer[i]; + if (a > maxAbs) maxAbs = a; + if (i >= 2048 && a > maxAbsLast2k) maxAbsLast2k = a; + } + TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(50, maxAbsLast2k, + "TRIANGLE output must be silent (HPF-decayed) when length gate is closed"); + (void)maxAbs; // for diagnostics if the first-window check ever needs to be tightened +} + +// TRIANGLE voice with linear counter enabled and reloadValue=0 +// (reloaded to 0 on every quarter-clock as long as control=0 and +// reloadFlag stays set, but with control=0 the flag is cleared after +// one reload ΓåÆ counter decrements from 0 ΓåÆ stays at 0). +void test_triangle_gate_closes_when_linear_zero(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); + + int slot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + slot = s; + break; + } + } + TEST_ASSERT_TRUE(slot >= 0); + + apu.setVoiceNesLinearEnabled(slot, true); + apu.setVoiceNesLinearCounter(slot, 0, true); // reloadValue=0 + + // After 1 quarter-clock: counter is reloaded to 0 (because reloadFlag + // is set, it loads reloadValue=0), and reloadFlag is cleared because + // control=0 (default halt). The decrement branch is a no-op because + // counter is already 0. Subsequent ticks: counter stays at 0. + int safety = 0; + while (apu.getNesFrameCounterQuarterTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(slot)); + + // Render a long window and check the bulk of samples. The first few + // samples have a DC-blocker transient, but the HPF decays at 0.995 + // per sample. An active triangle at volume 0.5 produces magnitudes + // in the thousands; gate-closed output should be < 50 in the + // HPF-decayed region. + int16_t gateBuffer[4096] = {0}; + apu.generateSamples(gateBuffer, 4096); + int16_t maxAbsLast2k = 0; + for (int i = 2048; i < 4096; ++i) { + const int16_t a = (gateBuffer[i] < 0) ? (int16_t)-gateBuffer[i] : gateBuffer[i]; + if (a > maxAbsLast2k) maxAbsLast2k = a; + } + TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(50, maxAbsLast2k, + "TRIANGLE output must be silent (HPF-decayed) when linear gate is closed"); + + // The voice must still be enabled (it can resume when a fresh + // $4008 write reloads the counter to a non-zero value). + TEST_ASSERT_TRUE_MESSAGE(apu.isVoiceEnabledForTesting(slot), + "TRIANGLE must stay enabled when linear gate is closed"); +} + +// TRIANGLE voice: phase must advance when the gate is open, and must +// NOT advance when the gate is closed. We capture the phase before and +// after two render windows: one with the gate open (default OFF) and +// one with the gate closed (linear counter at 0 with linearEnabled=true). +void test_triangle_phase_does_not_advance_when_gated(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::TRIANGLE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[1000] = {0}; + apu.generateSamples(buffer, 1000); // dispatch + 1000 samples (warms phase) + + int slot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + slot = s; + break; + } + } + TEST_ASSERT_TRUE(slot >= 0); + + // Gate is open by default (linearEnabled=false). Phase must advance. + const float phaseBefore = apu.getVoicePhaseForTesting(slot); + apu.generateSamples(buffer, 1000); + const float phaseAfterOpen = apu.getVoicePhaseForTesting(slot); + TEST_ASSERT_TRUE_MESSAGE(phaseAfterOpen != phaseBefore, + "TRIANGLE phase must advance when gate is open"); + + // Close the gate: enable linear with counter=0. Without any quarter- + // clock tick, the counter starts at 0 and the gate is immediately + // closed (we don't even need the frame counter enabled for this). + apu.setVoiceNesLinearEnabled(slot, true); + apu.setVoiceNesLinearCounter(slot, 0, false); // reloadValue=0, no auto-reload + // Note: reloadFlag=false so no reload happens; counter stays at 0. + + const float phaseBeforeClosed = apu.getVoicePhaseForTesting(slot); + apu.generateSamples(buffer, 1000); + const float phaseAfterClosed = apu.getVoicePhaseForTesting(slot); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(phaseBeforeClosed, phaseAfterClosed, + "TRIANGLE phase must NOT advance when gate is closed"); +} + +// SINE voices must keep `nesLinearCounter.linearEnabled == false` +// permanently, just like the length counter. The quarter-clock dispatch +// must skip them and a SINE voice must continue to produce audio. +void test_triangle_does_not_affect_sine_saw(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + AudioCommand sineCmd{}; + sineCmd.type = AudioCommandType::PLAY_EVENT; + sineCmd.event.type = WaveType::SINE; + sineCmd.event.frequency = 440.0f; + sineCmd.event.duration = 2.0f; // long enough that the voice stays armed + sineCmd.event.volume = 0.5f; + sineCmd.event.loop = false; + TEST_ASSERT_TRUE(apu.submitCommand(sineCmd)); + + int16_t buffer[256] = {0}; + apu.generateSamples(buffer, 256); + + int sineSlot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + sineSlot = s; + break; + } + } + TEST_ASSERT_TRUE_MESSAGE(sineSlot >= 0, "SINE voice must be present in SFX pool"); + + // Render ~1 s. The voice must stay armed, and the linear counter + // must stay at its default (linearEnabled=false, counter=0). + int total = 0; + while (total < 44100) { + apu.generateSamples(buffer, 256); + total += 256; + } + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(sineSlot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearEnabledForTesting(sineSlot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(sineSlot)); +} + +// ============================================================================= +// Hito 2 M6: NES sweep unit (PULSE) +// ============================================================================= +// +// All sweep tests are isolated per `ApuCore` instance. The sweep is OFF +// by default (default-OFF invariant), so the canonical track PCM is +// unaffected. Tests explicitly opt in by calling `setVoiceNesSweep` / +// `setVoiceNesTimer` and, when the half-clock dispatch is needed, by +// enabling the NES frame counter with `setNesFrameCounterMode(0)`. +// +// The `maxAbs < 50` window (HPF-decayed samples 2048-4096) pattern is +// borrowed from the M5 linear counter tests: the first ~1000 samples +// carry a DC-blocker transient from the previous render window, so the +// "did the voice actually silence" check runs in the post-transient +// region where the residual is < 1% of the active level. + +// After init+reset, every voice's `nesSweepUnit` must be in the +// default state: sweepEnabled=false, period=0, negate=false, shift=0, +// reloadFlag=false, dividerCounter=0, isPulse2=false, timer=0, muted=false. +void test_sweep_initial_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + for (int slot = 0; slot < ApuCore::MAX_VOICES; ++slot) { + TEST_ASSERT_FALSE(apu.getVoiceNesSweepEnabledForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepNegateForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepShiftForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepDividerForTesting(slot)); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesSweepTimerForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(slot)); + } +} + +// PULSE 1, no negate, shift=1, period=7. Timer 256 ΓåÆ 256+128=384 after +// one half-clock. The divider reload branch (which also fires when +// `dividerCounter == 0`) sets the divider to P+1=8 WITHOUT decrementing +// on the same tick ΓÇö that decrement only happens on the next half-clock +// when `dividerCounter != 0` and `reloadFlag` is clear. So after 1 +// half-clock: divider == 8. +void test_sweep_setup_and_target_calculation(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesSweepUnitEnabled(0, true); // opt into the NES sweep unit + apu.setVoiceNesTimer(0, 256); + apu.setVoiceNesSweep(0, true, 7, false, 1, false); + + // Render until 1 half-clock has fired (~367 samples at 44100 Hz). + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, apu.getNesFrameCounterHalfTickCountForTesting()); + + // target = 256 + (256 >> 1) = 256 + 128 = 384. + TEST_ASSERT_EQUAL_UINT16(384, apu.getVoiceNesSweepTimerForTesting(0)); + // Divider was 0 at the tick: the timer-update branch ran, then the + // reload branch (which also fires on `dividerCounter == 0`) reloaded + // to P+1 = 8. No decrement on the same tick. + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesSweepDividerForTesting(0)); +} + +// PULSE 1 negate: target = period + (-change - 1). With period=20, +// shift=1 ΓåÆ change=10, so target = 20 - 10 - 1 = 9. +void test_sweep_negate_pulse1_ones_complement(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesSweepUnitEnabled(0, true); // opt into the NES sweep unit + apu.setVoiceNesSweep(0, true, 7, true, 1, false); // PULSE 1, negate + apu.setVoiceNesTimer(0, 20); + + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, apu.getNesFrameCounterHalfTickCountForTesting()); + + // 20 + (-10 - 1) = 9. + TEST_ASSERT_EQUAL_UINT16(9, apu.getVoiceNesSweepTimerForTesting(0)); +} + +// PULSE 2 negate: target = period + (-change). With period=20, shift=1 +// ΓåÆ change=10, so target = 20 - 10 = 10. (Exactly 1 higher than PULSE 1.) +void test_sweep_negate_pulse2_two_complement(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesSweepUnitEnabled(0, true); // opt into the NES sweep unit + apu.setVoiceNesSweep(0, true, 7, true, 1, true); // PULSE 2, negate + apu.setVoiceNesTimer(0, 20); + + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, apu.getNesFrameCounterHalfTickCountForTesting()); + + // 20 + (-10) = 10. + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesSweepTimerForTesting(0)); + // Sanity: PULSE 1 vs PULSE 2 differ by exactly 1 with these params. + // (Already verified in test_sweep_negate_pulse1_ones_complement.) +} + +// Sweep enabled with period=1500, shift=1, no negate. Target = 1500+750 +// = 2250 > 0x7FF. The muting flag must flip to true, and the rendered +// audio must be silent (HPF-decayed window maxAbs < 50). +void test_sweep_muting_target_overflow(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + // A PULSE voice so the hot path has something to gate. Arm the + // sweep on the slot it actually landed on, not slot 0 - the muting + // gate is per-voice, so targeting the wrong slot would leave the + // audible voice untouched. + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // Arm the sweep so target overflows. setVoiceNesTimer recomputes + // the muting flag immediately, so the very next sample is gated. + apu.setVoiceNesSweepUnitEnabled(slot, true); + apu.setVoiceNesTimer(slot, 1500); + apu.setVoiceNesSweep(slot, true, 7, false, 1, false); + + // Muting flag must be true (target = 1500 + 750 = 2250 > 0x7FF). + TEST_ASSERT_TRUE(apu.getVoiceNesSweepMutedForTesting(slot)); + + // Render and verify the HPF-decayed region is silent. We need a + // loop voice so the audio thread keeps producing samples, but the + // sweep muting forces them to 0. + int16_t renderBuffer[4096] = {0}; + apu.generateSamples(renderBuffer, 4096); + int16_t maxAbs = 0; + for (int i = 2048; i < 4096; ++i) { + const int16_t a = (renderBuffer[i] < 0) ? (int16_t)-renderBuffer[i] : renderBuffer[i]; + if (a > maxAbs) maxAbs = a; + } + TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(50, maxAbs, + "PULSE output must be silent (HPF-decayed) when sweep target overflows"); +} + +// Sweep enabled but current period < 8 (the "low period" muting case). +// timer=4 ΓåÆ muted must be true immediately, and rendered output is 0. +void test_sweep_muting_current_period_too_low(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // timer = 4 -> current period < 8 -> muted = true. + apu.setVoiceNesSweepUnitEnabled(slot, true); + apu.setVoiceNesTimer(slot, 4); + apu.setVoiceNesSweep(slot, true, 7, false, 1, false); + + TEST_ASSERT_TRUE(apu.getVoiceNesSweepMutedForTesting(slot)); + + int16_t renderBuffer[4096] = {0}; + apu.generateSamples(renderBuffer, 4096); + int16_t maxAbs = 0; + for (int i = 2048; i < 4096; ++i) { + const int16_t a = (renderBuffer[i] < 0) ? (int16_t)-renderBuffer[i] : renderBuffer[i]; + if (a > maxAbs) maxAbs = a; + } + TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(50, maxAbs, + "PULSE output must be silent when current period < 8"); +} + +// Divider reload behaviour. P=2, so on a half-clock with dividerCounter +// starting at 0: the update branch sees `dividerCounter == 0`, the +// reload branch sets it to P+1=3. The decrement branch only fires when +// `dividerCounter != 0` and `reloadFlag` is clear, so on the reload +// tick itself there is NO decrement. Subsequent ticks decrement +// normally (3 ΓåÆ 2 ΓåÆ 1). +void test_sweep_divider_reload(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesSweepUnitEnabled(0, true); // opt into the NES sweep unit + apu.setVoiceNesTimer(0, 256); + apu.setVoiceNesSweep(0, true, 2, false, 1, false); + + int16_t buffer[64] = {0}; + + // Half-clock 1: reload to P+1=3 (no decrement on the reload tick). + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(3, apu.getVoiceNesSweepDividerForTesting(0)); + + // Half-clock 2: decrement 3 ΓåÆ 2. + safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(2, apu.getVoiceNesSweepDividerForTesting(0)); + + // Half-clock 3: decrement 2 ΓåÆ 1. + safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 3 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(3, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(1, apu.getVoiceNesSweepDividerForTesting(0)); +} + +// TRIANGLE/NOISE voices must be skipped by the half-clock sweep dispatch. +// Even when setVoiceNesSweep is called on them, the timer must not be +// updated. We find the SFX slot the TRIANGLE voice landed in (the +// default TRIANGLE PLAY_EVENT goes to the SFX subpool, not the music +// subpool at slots 0-3) and arm the sweep on that exact slot. +void test_sweep_does_not_affect_triangle_noise(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + AudioCommand tri{}; + tri.type = AudioCommandType::PLAY_EVENT; + tri.event.type = WaveType::TRIANGLE; + tri.event.frequency = 440.0f; + tri.event.duration = 0.0f; // loop + tri.event.volume = 0.5f; + tri.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(tri)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); + + // Find the SFX slot the TRIANGLE voice landed in. + int slot = -1; + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + slot = s; + break; + } + } + TEST_ASSERT_TRUE(slot >= 0); + + // Arm the sweep on that exact slot ΓÇö the half-clock dispatch + // checks `type == PULSE` and must skip it. + apu.setVoiceNesSweepUnitEnabled(slot, true); // opt in: proves the TYPE gate skips it + apu.setVoiceNesTimer(slot, 256); + apu.setVoiceNesSweep(slot, true, 7, false, 1, false); + + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, apu.getNesFrameCounterHalfTickCountForTesting()); + + // Timer must NOT have been updated by the dispatcher ΓÇö the setter + // stored 256 and the dispatcher skipped it (TRIANGLE Γëá PULSE), so + // it stays 256. (If we had armed a PULSE voice, the dispatcher + // would have updated 256 ΓåÆ 384.) + TEST_ASSERT_EQUAL_UINT16(256, apu.getVoiceNesSweepTimerForTesting(slot)); +} + +// setVoiceNesSweep sets reloadFlag=true. The next half-clock tick must +// clear it (the reload branch consumes the flag). +void test_sweep_reload_flag_cleared(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesSweepUnitEnabled(0, true); // opt into the NES sweep unit + apu.setVoiceNesTimer(0, 256); + apu.setVoiceNesSweep(0, true, 7, false, 1, false); + + // The setter sets reloadFlag=true. Render until 1 half-clock has + // fired: the dispatcher consumed the flag and reloaded the divider + // to P+1=8 (the reload branch fires on `dividerCounter == 0` even + // when reloadFlag has already been consumed). + int16_t buffer[64] = {0}; + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 1 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(1, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT16(384, apu.getVoiceNesSweepTimerForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(8, apu.getVoiceNesSweepDividerForTesting(0)); + + // Run another half-clock: reloadFlag is now clear (consumed on the + // previous tick) and dividerCounter=8, so the decrement branch + // fires. The divider must drop to 7. If reloadFlag had been + // sticky, the divider would have reloaded to 8 again. + safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 + && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, apu.getNesFrameCounterHalfTickCountForTesting()); + TEST_ASSERT_EQUAL_UINT8(7, apu.getVoiceNesSweepDividerForTesting(0)); +} + +// THE NES BUG: even when E=0 and SSS=0, the muting flag must still be +// evaluated. timer=1500 with all sweep fields zero ΓåÆ muted = (1500<8) || +// (1500+0>0x7FF) ΓåÆ false. timer=1500 with shift=0 still computes +// target = 1500 + 0 = 1500 Γëñ 0x7FF, so not muted. We use timer=4 with +// shift=0 to verify the "current period < 8" path is the one that +// forces muted even with the sweep "disabled". +void test_sweep_muting_persists_when_disabled(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // The unit is armed, but every register bit is off: + // E=0, period=0, negate=false, shift=0. + apu.setVoiceNesSweepUnitEnabled(slot, true); + apu.setVoiceNesSweep(slot, false, 0, false, 0, false); + // timer = 4 -> current period < 8 -> muted = true EVEN THOUGH the + // sweep is "disabled" (E=0, shift=0). This is the NES bug: + // muting is evaluated continuously. + apu.setVoiceNesTimer(slot, 4); + + TEST_ASSERT_FALSE(apu.getVoiceNesSweepEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepShiftForTesting(slot)); + TEST_ASSERT_TRUE(apu.getVoiceNesSweepMutedForTesting(slot)); + + int16_t renderBuffer[4096] = {0}; + apu.generateSamples(renderBuffer, 4096); + int16_t maxAbs = 0; + for (int i = 2048; i < 4096; ++i) { + const int16_t a = (renderBuffer[i] < 0) ? (int16_t)-renderBuffer[i] : renderBuffer[i]; + if (a > maxAbs) maxAbs = a; + } + TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(50, maxAbs, + "PULSE output must be silent (NES bug) when period<8 with E=0, SSS=0"); +} + +// Regression: the sweep unit is opt-in per voice. A PULSE voice that +// never armed it must keep playing when the frame counter is switched +// on. Before the opt-in flag existed, the half-clock dispatch evaluated +// every PULSE voice, and the default `timer == 0` satisfied the +// "current period < 8" muting rule, so `setNesFrameCounterMode` alone +// silenced every legacy PULSE voice. +void test_sweep_unarmed_voice_survives_frame_counter(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // Nothing NES-related armed on this voice at all. + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(slot)); + + // Run well past several half-clocks. + for (int i = 0; i < 20; ++i) { + apu.generateSamples(buffer, 256); + } + TEST_ASSERT_GREATER_THAN_INT(0, apu.getNesFrameCounterHalfTickCountForTesting()); + + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(slot)); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(4000, + renderPeakMagnitude(apu, buffer, 256), + "an unarmed PULSE voice must stay audible once the frame counter runs"); +} + +// Disarming the sweep unit must clear the muting flag, otherwise the +// per-sample gate would keep silencing a voice that is no longer under +// NES sweep control. +void test_sweep_disarm_clears_muting(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + apu.setVoiceNesSweepUnitEnabled(slot, true); + apu.setVoiceNesTimer(slot, 4); // period < 8 -> muted + TEST_ASSERT_TRUE(apu.getVoiceNesSweepMutedForTesting(slot)); + + apu.setVoiceNesSweepUnitEnabled(slot, false); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(slot)); + + // And it must stay clear across half-clocks (dispatch skips it). + for (int i = 0; i < 20; ++i) { + apu.generateSamples(buffer, 256); + } + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(slot)); +} + +// The register-write setters must NOT arm the unit by themselves. This +// mirrors M4 / M5 / M7, where `setVoiceNesLength`, `setVoiceNesLinearCounter` +// and `setVoiceNesEnvelope` all leave their opt-in flag untouched. +void test_sweep_register_writes_do_not_arm_the_unit(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesSweep(0, true, 2, false, 1, false); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(0)); + + apu.setVoiceNesTimer(0, 4); // period < 8, but the unit is not armed + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(0)); + + // Arming afterwards must evaluate the muting rule immediately, using + // the register state written above. + apu.setVoiceNesSweepUnitEnabled(0, true); + TEST_ASSERT_TRUE(apu.getVoiceNesSweepMutedForTesting(0)); +} + +// ============================================================================= +// Hito 2 M13: TRIGGER_NES_LENGTH command +// ============================================================================= +// +// The queued counterpart of `setVoiceNesLength`. It exists so a consumer can +// fire a NES-length-gated note across the game/audio thread boundary, which +// the direct setters cannot do safely. Unlike the raw setter it also arms the +// NES channel, so the command is self-sufficient (see the header docs for why +// that deviates from the `$4015` / `$4003` register split). + +// The command must load the length counter from the LUT and latch halt, +// exactly as the direct setter does. +void test_trigger_nes_length_loads_counter(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = (uint8_t)slot; + cmd.nesLengthIndex = 3; // kNesLengthLut[3] = 2 + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + apu.generateSamples(buffer, 64); // dispatch the command + + TEST_ASSERT_TRUE(apu.getVoiceNesChannelEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT16(2, apu.getVoiceNesLengthCounterForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(3, apu.getVoiceNesLengthIndexForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLengthHaltForTesting(slot)); +} + +// The command arms the NES channel itself, so it works on a slot that never +// received a `setVoiceNesChannelEnabled` call. Without this the queued path +// would be unusable: the enable setter is not reachable from the game thread. +void test_trigger_nes_length_arms_the_channel(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + TEST_ASSERT_FALSE(apu.getVoiceNesChannelEnabledForTesting(0)); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = 0; + cmd.nesLengthIndex = 0; // kNesLengthLut[0] = 10 + cmd.nesLengthHalt = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); + + TEST_ASSERT_TRUE(apu.getVoiceNesChannelEnabledForTesting(0)); + TEST_ASSERT_EQUAL_UINT16(10, apu.getVoiceNesLengthCounterForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLengthHaltForTesting(0)); +} + +// M13 coordinates the sub-units: the length load must also raise the M7 +// envelope start flag and the M5 linear counter reload flag, mirroring the +// documented side effects of a $4003 / $4007 / $400B / $400F write. +void test_trigger_nes_length_raises_subunit_flags(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = 0; + cmd.nesLengthIndex = 5; + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); + + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLinearReloadFlagForTesting(0)); +} + +// The direct setter must raise the same two flags — one behaviour, two entry +// points. If these ever diverge, a consumer would get different results from +// the queue than from the setter. +void test_set_voice_nes_length_raises_subunit_flags(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 5, false); + + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeStartFlagForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLinearReloadFlagForTesting(0)); +} + +// The raised flags must actually drive the sub-units, not just sit there. +// TRIANGLE with the linear counter opted in: the trigger raises the reload +// flag, and the next quarter-clock must load the counter from reloadValue. +// This is the $400B behaviour the milestone was deferred for. +void test_trigger_nes_length_reloads_linear_counter(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + apu.setVoiceNesLinearEnabled(0, true); + apu.setVoiceNesLinearCounter(0, 12, false); // reloadValue=12, flag clear + + int16_t buffer[64] = {0}; + advanceQuarterClocks(apu, buffer, 1); + // No reload flag yet, so the counter stayed at its initial 0. + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(0)); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = 0; + cmd.nesLengthIndex = 0; + cmd.nesLengthHalt = true; // control set -> reload flag is not auto-cleared + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + apu.generateSamples(buffer, 64); + TEST_ASSERT_TRUE(apu.getVoiceNesLinearReloadFlagForTesting(0)); + + advanceQuarterClocks(apu, buffer, 1); + TEST_ASSERT_EQUAL_UINT8(12, apu.getVoiceNesLinearCounterForTesting(0)); +} + +// Out-of-range length indices are clamped to 31, matching the setter. +void test_trigger_nes_length_clamps_index(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = 0; + cmd.nesLengthIndex = 200; // out of range + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); + + TEST_ASSERT_EQUAL_UINT8(31, apu.getVoiceNesLengthIndexForTesting(0)); + TEST_ASSERT_EQUAL_UINT16(nes_apu::kNesLengthLut[31], + apu.getVoiceNesLengthCounterForTesting(0)); +} + +// An out-of-range slot must be ignored without touching any voice. +void test_trigger_nes_length_ignores_bad_slot(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = (uint8_t)ApuCore::MAX_VOICES; // out of range + cmd.nesLengthIndex = 0; + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); // must not crash + + for (int s = 0; s < ApuCore::MAX_VOICES; ++s) { + TEST_ASSERT_FALSE(apu.getVoiceNesChannelEnabledForTesting(s)); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(s)); + } +} + +// End to end: trigger a short length, let the half-clocks run, and confirm +// the length counter silenced the voice without the caller ever computing a +// sample budget. This is the reason M13 exists. +void test_trigger_nes_length_silences_voice_end_to_end(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(slot)); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = (uint8_t)slot; + cmd.nesLengthIndex = 3; // kNesLengthLut[3] = 2 -> 2 half-clocks + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + apu.generateSamples(buffer, 64); + + int safety = 0; + while (apu.getNesFrameCounterHalfTickCountForTesting() < 2 && safety < 1000) { + apu.generateSamples(buffer, 64); + ++safety; + } + TEST_ASSERT_GREATER_OR_EQUAL_INT(2, apu.getNesFrameCounterHalfTickCountForTesting()); + + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(slot)); + TEST_ASSERT_FALSE(apu.isVoiceEnabledForTesting(slot)); +} + +// ============================================================================= +// Hito 2 M17: initNesSafe / InstrumentPreset::nesAccurate +// ============================================================================= +// +// `nesAccurate` on a preset makes `initVoiceFromEvent` reset the voice's NES +// sub-unit fields to the canonical silent state from the nesdev APU_basics +// `@regs` array before the note starts: +// +// PULSE $4000=$30 $4001=$08 $4002=$00 $4003=$00 +// TRIANGLE $4008=$80 $400A=$00 $400B=$00 +// NOISE $400C=$30 $400E=$00 $400F=$00 +// +// $30 decodes to L=1 (halt/loop), C=1 (constant volume), VVVV=0 — silent and +// frozen. $08 decodes to E=0, P=0, N=1, SSS=0. $80 sets the TRIANGLE control +// bit, which is the same shared halt flag. +// +// It deliberately does NOT opt the voice into any sub-unit: this is the clean +// slate you get BEFORE enabling NES mode, not the enable itself. + +// Dispatches a one-shot note of `type` using `preset` and returns the SFX slot +// it landed on. `scratch` must hold at least 256 samples. +// +// PRECONDITION: no other SFX voice may be playing. This returns the first +// ENABLED slot, so with another voice already running it reports that one +// instead of the note just dispatched. Stop the previous voice with +// STOP_CHANNEL first when retriggering on the same slot. +static int playPresetNote(ApuCore& apu, int16_t* scratch, + WaveType type, const InstrumentPreset& preset) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = type; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.5f; + cmd.event.duty = 0.5f; + cmd.event.loop = true; + cmd.event.preset = &preset; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + apu.generateSamples(scratch, 256); + + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + return s; + } + } + TEST_FAIL_MESSAGE("preset note did not land on an SFX slot"); + return -1; +} + +// Asserts the shared part of the canonical safe state (length counter + +// envelope), which is identical for PULSE and NOISE. +static void assertNesSafeCommon(ApuCore& apu, int slot) { + // $4015 silenced: the channel is not armed. + TEST_ASSERT_FALSE(apu.getVoiceNesChannelEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesLengthCounterForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLengthIndexForTesting(slot)); + // L bit of $30 / $80: halt set, so nothing counts down on its own. + TEST_ASSERT_TRUE(apu.getVoiceNesLengthHaltForTesting(slot)); + // C=1, VVVV=0: constant volume zero -> silent. + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeConstVolumeForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeVolumeForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDecayLevelForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeDividerForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeOutputForTesting(slot)); + // The length-load side effect of the $4003 / $400F write. + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeStartFlagForTesting(slot)); + // Opt-in flags stay OFF: this is the clean slate, not the enable. + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeEnabledForTesting(slot)); +} + +void test_nes_safe_pulse(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + + int16_t buffer[256] = {0}; + const int slot = playPresetNote(apu, buffer, WaveType::PULSE, preset); + + assertNesSafeCommon(apu, slot); + + // $4001 = $08 -> E=0, P=0, N=1, SSS=0. + TEST_ASSERT_FALSE(apu.getVoiceNesSweepEnabledForTesting(slot)); + TEST_ASSERT_TRUE(apu.getVoiceNesSweepNegateForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepShiftForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepDividerForTesting(slot)); + // $4002 / $4003 = $00 -> timer 0. + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesSweepTimerForTesting(slot)); + // Not armed, so the muting rule must not have fired despite timer < 8. + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepMutedForTesting(slot)); +} + +void test_nes_safe_triangle(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + InstrumentPreset preset = INSTR_TRIANGLE_LEAD; + preset.nesAccurate = true; + + int16_t buffer[256] = {0}; + const int slot = playPresetNote(apu, buffer, WaveType::TRIANGLE, preset); + + assertNesSafeCommon(apu, slot); + + // $4008 = $80 -> control bit set (the shared halt, asserted above), + // reload value 0. $400A / $400B = $00. + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearCounterForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearReloadValueForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearEnabledForTesting(slot)); +} + +void test_nes_safe_noise(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + + int16_t buffer[256] = {0}; + const int slot = playPresetNote(apu, buffer, WaveType::NOISE, preset); + + assertNesSafeCommon(apu, slot); + + // NOISE has no sweep unit; the $4001 negate bit must not be applied. + TEST_ASSERT_FALSE(apu.getVoiceNesSweepNegateForTesting(slot)); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesSweepTimerForTesting(slot)); +} + +// The whole point of the safe state: a voice carrying leftover NES state from +// a previous note must come back to the canonical values, not inherit them. +void test_nes_safe_clears_previous_state(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + // Dirty every sub-unit on that slot. + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 7, false); + apu.setVoiceNesEnvelope(slot, false, false, 9); + apu.setVoiceNesEnvelopeEnabled(slot, true); + apu.setVoiceNesSweepUnitEnabled(slot, true); + apu.setVoiceNesSweep(slot, true, 3, false, 2, true); + apu.setVoiceNesTimer(slot, 400); + apu.setVoiceNesLinearEnabled(slot, true); + apu.setVoiceNesLinearCounter(slot, 20, true); + TEST_ASSERT_EQUAL_UINT16(nes_apu::kNesLengthLut[7], + apu.getVoiceNesLengthCounterForTesting(slot)); + + // Stop it so the retrigger lands on the same slot, then replay with a + // nesAccurate preset. + AudioCommand stop{}; + stop.type = AudioCommandType::STOP_CHANNEL; + stop.channelIndex = (uint8_t)slot; + TEST_ASSERT_TRUE(apu.submitCommand(stop)); + apu.generateSamples(buffer, 64); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + const int slot2 = playPresetNote(apu, buffer, WaveType::PULSE, preset); + TEST_ASSERT_EQUAL_INT(slot, slot2); + + assertNesSafeCommon(apu, slot); + TEST_ASSERT_EQUAL_UINT16(0, apu.getVoiceNesSweepTimerForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesSweepShiftForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepEnabledForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearEnabledForTesting(slot)); + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesLinearReloadValueForTesting(slot)); +} + +// Default path: nesAccurate is false, so the NES fields are never touched by +// initVoiceFromEvent. This is the invariant that keeps the golden PCM stable. +void test_nes_safe_off_by_default(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + + apu.setVoiceNesChannelEnabled(slot, true); + apu.setVoiceNesLength(slot, 7, false); + + AudioCommand stop{}; + stop.type = AudioCommandType::STOP_CHANNEL; + stop.channelIndex = (uint8_t)slot; + TEST_ASSERT_TRUE(apu.submitCommand(stop)); + apu.generateSamples(buffer, 64); + + // INSTR_PULSE_LEAD ships with nesAccurate = false. + TEST_ASSERT_FALSE(INSTR_PULSE_LEAD.nesAccurate); + const int slot2 = playPresetNote(apu, buffer, WaveType::PULSE, INSTR_PULSE_LEAD); + TEST_ASSERT_EQUAL_INT(slot, slot2); + + // STOP_CHANNEL reset the voice, but the point is that replaying without + // nesAccurate does not install the canonical safe state: halt stays clear. + TEST_ASSERT_FALSE(apu.getVoiceNesLengthHaltForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeConstVolumeForTesting(slot)); + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeStartFlagForTesting(slot)); +} + +// SINE and SAW are our own extensions with no NES counterpart. A nesAccurate +// preset on them must be inert. +void test_nes_safe_does_not_affect_sine_saw(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + + int16_t buffer[256] = {0}; + const WaveType types[2] = {WaveType::SINE, WaveType::SAW}; + for (int i = 0; i < 2; ++i) { + ApuCore fresh; + fresh.init(44100); + fresh.reset(); + const int slot = playPresetNote(fresh, buffer, types[i], preset); + + // Everything still at construction defaults - no safe state applied. + TEST_ASSERT_FALSE(fresh.getVoiceNesLengthHaltForTesting(slot)); + TEST_ASSERT_FALSE(fresh.getVoiceNesEnvelopeConstVolumeForTesting(slot)); + TEST_ASSERT_FALSE(fresh.getVoiceNesEnvelopeStartFlagForTesting(slot)); + TEST_ASSERT_FALSE(fresh.getVoiceNesSweepNegateForTesting(slot)); + } +} + +// The safe state must be genuinely silent when the sub-units are armed on top +// of it: constant volume 0 means the envelope contributes nothing. +void test_nes_safe_state_is_silent_when_armed(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setNesFrameCounterMode(0); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + + int16_t buffer[256] = {0}; + const int slot = playPresetNote(apu, buffer, WaveType::PULSE, preset); + + // Arm the envelope on top of the safe state and let it clock. Several + // quarter-clocks, not one: the note rendered at full ADSR volume before + // the envelope took over, and the output HPF needs ~2000 samples to ring + // that step down below the noise floor (R=0.995 per sample). + apu.setVoiceNesEnvelopeEnabled(slot, true); + advanceQuarterClocks(apu, buffer, 8); + + TEST_ASSERT_EQUAL_UINT8(0, apu.getVoiceNesEnvelopeOutputForTesting(slot)); + TEST_ASSERT_LESS_OR_EQUAL_INT32_MESSAGE(50, + renderPeakMagnitude(apu, buffer, 256), + "the canonical safe state must be silent once the envelope is armed"); + + // Silent because constant volume is 0 (C=1, VVVV=0) — NOT because the + // decay path ran out and disabled the voice. Without the safe state the + // envelope would start at constVolume=false / decayLevel=0 and the first + // divider underflow would kill the channel, which is silence for the + // wrong reason. The halt bit ($30 L=1) is what keeps it alive. + TEST_ASSERT_TRUE_MESSAGE(apu.isVoiceEnabledForTesting(slot), + "the safe state must silence via constant volume, not by disabling the voice"); + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeConstVolumeForTesting(slot)); +} + +// ============================================================================= +// Hito 3 M8: NES NOISE period LUT +// ============================================================================= +// +// The NES noise channel picks its LFSR clock from a 16-entry table indexed by +// $400E bits 3-0, in CPU cycles. We keep the canonical NTSC table in CPU +// cycles and convert to a sample countdown at the current sample rate, so the +// same index means the same timbre at 22050, 44100 or 48000 Hz. +// +// Opt-in per voice, mirroring M2's `pulseDutyIndex`: 255 = off (legacy +// frequency-derived period), 0..15 select a LUT entry. + +// Dispatches a looping NOISE note and returns the SFX slot it landed on. +static int startLoopingNoiseVoice(ApuCore& apu, int16_t* scratch) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::NOISE; + cmd.event.frequency = 1000.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + apu.generateSamples(scratch, 256); + + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + return s; + } + } + TEST_FAIL_MESSAGE("looping NOISE event did not land on an SFX slot"); + return -1; +} + +// The 16 canonical NTSC values, in CPU cycles, from +// https://www.nesdev.org/wiki/APU_Noise. A typo here would silently retune +// every NES-mode noise voice, so pin the table itself. +void test_noise_period_lut(void) { + const uint16_t expected[16] = { + 4, 8, 16, 32, 64, 96, 128, 160, + 202, 254, 380, 508, 762, 1016, 2034, 4068 + }; + for (int i = 0; i < 16; ++i) { + TEST_ASSERT_EQUAL_UINT16(expected[i], nes_apu::kNesNoisePeriodLutNtsc[i]); + } + + // Conversion to samples: cycles * sampleRate / CPU_NTSC, min 1 sample. + // At 44100 the divisor is ~40.585, so index 15 (4068 cycles) is ~100 + // samples and index 0 (4 cycles) underflows to the 1-sample floor. + TEST_ASSERT_EQUAL_UINT32(1u, nes_apu::nesNoisePeriodToSamples(0, 44100)); + TEST_ASSERT_EQUAL_UINT32(100u, nes_apu::nesNoisePeriodToSamples(15, 44100)); + TEST_ASSERT_EQUAL_UINT32(25u, nes_apu::nesNoisePeriodToSamples(13, 44100)); + // Halving the sample rate halves the sample period. + TEST_ASSERT_EQUAL_UINT32(50u, nes_apu::nesNoisePeriodToSamples(15, 22050)); + // Out-of-range index is clamped to the last entry. + TEST_ASSERT_EQUAL_UINT32(nes_apu::nesNoisePeriodToSamples(15, 44100), + nes_apu::nesNoisePeriodToSamples(200, 44100)); + // Invalid sample rate must not divide by zero. + TEST_ASSERT_EQUAL_UINT32(1u, nes_apu::nesNoisePeriodToSamples(15, 0)); +} + +// Default off: the voice keeps the legacy frequency-derived period, which is +// what preserves the canonical golden PCM. +void test_noise_lut_default_off(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + + TEST_ASSERT_EQUAL_UINT8(255, apu.getVoiceNesNoiseLutIndexForTesting(slot)); + // 44100 / 1000 Hz = 44 samples, the legacy path. + TEST_ASSERT_EQUAL_UINT32(44u, apu.getVoiceNoisePeriodForTesting(slot)); +} + +// Selecting a LUT index must retune the voice immediately, without waiting +// for the next note. +void test_noise_lut_sets_period_from_table(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + + apu.setVoiceNesNoiseLutMode(slot, 15); + TEST_ASSERT_EQUAL_UINT8(15, apu.getVoiceNesNoiseLutIndexForTesting(slot)); + TEST_ASSERT_EQUAL_UINT32(100u, apu.getVoiceNoisePeriodForTesting(slot)); + + apu.setVoiceNesNoiseLutMode(slot, 10); + TEST_ASSERT_EQUAL_UINT32(nes_apu::nesNoisePeriodToSamples(10, 44100), + apu.getVoiceNoisePeriodForTesting(slot)); +} + +// The mode is a per-slot setting, like M2's duty mode: it survives a new note +// on the same slot instead of being reset by `initVoiceFromEvent`. +void test_noise_lut_applies_on_new_note(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + apu.setVoiceNesNoiseLutMode(slot, 12); + + AudioCommand stop{}; + stop.type = AudioCommandType::STOP_CHANNEL; + stop.channelIndex = (uint8_t)slot; + TEST_ASSERT_TRUE(apu.submitCommand(stop)); + apu.generateSamples(buffer, 64); + + // reset() clears the mode, so re-arm it and retrigger. + apu.setVoiceNesNoiseLutMode(slot, 12); + const int slot2 = startLoopingNoiseVoice(apu, buffer); + TEST_ASSERT_EQUAL_INT(slot, slot2); + + // The note frequency (1000 Hz -> 44 samples) must NOT win over the LUT. + TEST_ASSERT_EQUAL_UINT32(nes_apu::nesNoisePeriodToSamples(12, 44100), + apu.getVoiceNoisePeriodForTesting(slot)); +} + +// Setting the sentinel returns the voice to the legacy frequency-derived +// period on the next note. +void test_noise_lut_off_restores_legacy_period(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + + apu.setVoiceNesNoiseLutMode(slot, 15); + TEST_ASSERT_EQUAL_UINT32(100u, apu.getVoiceNoisePeriodForTesting(slot)); + + apu.setVoiceNesNoiseLutMode(slot, 255); + TEST_ASSERT_EQUAL_UINT8(255, apu.getVoiceNesNoiseLutIndexForTesting(slot)); + TEST_ASSERT_EQUAL_UINT32(44u, apu.getVoiceNoisePeriodForTesting(slot)); +} + +// Indices 16..254 are invalid and must fall back to "off" rather than +// indexing past the table. +void test_noise_lut_invalid_index_falls_back_to_off(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + + apu.setVoiceNesNoiseLutMode(slot, 16); + TEST_ASSERT_EQUAL_UINT8(255, apu.getVoiceNesNoiseLutIndexForTesting(slot)); + TEST_ASSERT_EQUAL_UINT32(44u, apu.getVoiceNoisePeriodForTesting(slot)); +} + +// PULSE / TRIANGLE have no noise period; the setter must not disturb them. +void test_noise_lut_ignored_for_non_noise(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingPulseVoice(apu, buffer); + const uint32_t before = apu.getVoiceNoisePeriodForTesting(slot); + + apu.setVoiceNesNoiseLutMode(slot, 15); + + // The mode is stored (introspectable) but the PULSE voice is untouched. + TEST_ASSERT_EQUAL_UINT32(before, apu.getVoiceNoisePeriodForTesting(slot)); + TEST_ASSERT_TRUE(apu.isVoiceEnabledForTesting(slot)); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(4000, + renderPeakMagnitude(apu, buffer, 256), + "a PULSE voice must keep playing after a noise-LUT call"); +} + +// M17's safe state resets the NES SUB-UNITS, not the waveform mode selectors. +// `pulseDutyIndex` (M2) and `nesNoiseLutIndex` (M8) are deliberately outside +// its scope, so a nesAccurate note must not clear a mode the app selected. +void test_noise_lut_survives_nes_safe_init(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + apu.setVoiceNesNoiseLutMode(slot, 9); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + const int slot2 = playPresetNote(apu, buffer, WaveType::NOISE, preset); + TEST_ASSERT_EQUAL_INT(slot, slot2); + + TEST_ASSERT_EQUAL_UINT8(9, apu.getVoiceNesNoiseLutIndexForTesting(slot)); + TEST_ASSERT_EQUAL_UINT32(nes_apu::nesNoisePeriodToSamples(9, 44100), + apu.getVoiceNoisePeriodForTesting(slot)); +} + +// The LUT is sample-rate independent: the same index must give the same LFSR +// clock in Hz (within rounding) at 22050 and 44100. +void test_noise_lut_is_sample_rate_independent(void) { + int16_t buffer[256] = {0}; + + ApuCore apu44; + apu44.init(44100); + apu44.reset(); + const int slot44 = startLoopingNoiseVoice(apu44, buffer); + apu44.setVoiceNesNoiseLutMode(slot44, 14); + const float hz44 = 44100.0f / (float)apu44.getVoiceNoisePeriodForTesting(slot44); + + ApuCore apu22; + apu22.init(22050); + apu22.reset(); + const int slot22 = startLoopingNoiseVoice(apu22, buffer); + apu22.setVoiceNesNoiseLutMode(slot22, 14); + const float hz22 = 22050.0f / (float)apu22.getVoiceNoisePeriodForTesting(slot22); + + // Index 14 = 2034 CPU cycles -> 1789773 / 2034 ~= 880 Hz LFSR clock. + TEST_ASSERT_FLOAT_WITHIN(20.0f, 880.0f, hz44); + TEST_ASSERT_FLOAT_WITHIN(20.0f, hz44, hz22); +} + +// ============================================================================= +// Hito 4 M10: configurable master HPF +// ============================================================================= +// +// The DC blocker is `y[n] = x[n] - x[n-1] + R*y[n-1]`, where +// `R = exp(-2*pi*fc/fs)`. It used to carry a hardcoded R = 0.995, which pins +// the coefficient rather than the cutoff: that is ~35.2 Hz at 44100 Hz but +// ~17.6 Hz at 22050 Hz, so the filter changed character with the sample rate. +// +// M10 stores the cutoff in Hz and derives R from it and the current sample +// rate. The default stays the historical coefficient bit-for-bit so the +// canonical golden PCM does not move; calling the setter opts into the +// rate-independent behaviour. + +// Starts a continuous 110 Hz PULSE at 12.5% duty. The asymmetric duty is the +// point: it carries a large DC component (mean = 0.125 - 0.875 = -0.75 of full +// scale), which is exactly what a DC blocker is supposed to remove. +static int startLowPulseVoice(ApuCore& apu, int16_t* scratch) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 110.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 0.8f; + cmd.event.duty = 0.125f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + apu.generateSamples(scratch, 512); + + for (int s = ApuCore::SFX_VOICE_BASE; s < ApuCore::MAX_VOICES; ++s) { + if (apu.isVoiceEnabledForTesting(s)) { + return s; + } + } + TEST_FAIL_MESSAGE("looping low PULSE event did not land on an SFX slot"); + return -1; +} + +// Absolute DC (mean signed sample) of the first window after the note starts, +// with the HPF configured at `cutoffHz`. Negative `cutoffHz` leaves the legacy +// default in place. Signed mean rather than magnitude: it isolates exactly the +// quantity the filter targets, so bypass and any active corner separate by an +// order of magnitude instead of a few counts. +static int32_t measureHpfDc(float cutoffHz) { + ApuCore apu; + apu.init(44100); + apu.reset(); + if (cutoffHz >= 0.0f) { + apu.setHpfCutoffHz(cutoffHz); + } + + int16_t buffer[512] = {0}; + startLowPulseVoice(apu, buffer); // renders the first 512 samples + + int64_t total = 0; + for (int i = 0; i < 512; ++i) { + total += buffer[i]; + } + const int32_t dc = (int32_t)(total / 512); + return dc < 0 ? -dc : dc; +} + +// Untouched, the APU must keep the exact historical coefficient. This is what +// guarantees the golden PCM is unchanged by M10. +void test_hpf_default_is_legacy_coefficient(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + TEST_ASSERT_TRUE(apu.getHpfEnabledForTesting()); + TEST_ASSERT_EQUAL_FLOAT(0.995f, apu.getHpfRForTesting()); + TEST_ASSERT_EQUAL_INT32(32604, apu.getHpfRQ15ForTesting()); + // Negative cutoff is the "legacy coefficient" sentinel. + TEST_ASSERT_TRUE(apu.getHpfCutoffHzForTesting() < 0.0f); +} + +// Setting a cutoff derives R from it. 90 Hz is the NES analog HPF corner. +void test_hpf_cutoff_computes_coefficient(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setHpfCutoffHz(90.0f); + TEST_ASSERT_EQUAL_FLOAT(90.0f, apu.getHpfCutoffHzForTesting()); + // exp(-2*pi*90/44100) = 0.9872591 + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9872591f, apu.getHpfRForTesting()); + // The Q15 mirror must track the float coefficient. + TEST_ASSERT_INT32_WITHIN(2, + (int32_t)(0.9872591f * 32768.0f + 0.5f), apu.getHpfRQ15ForTesting()); + + apu.setHpfCutoffHz(240.0f); + // exp(-2*pi*240/44100) = 0.9663837 + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9663837f, apu.getHpfRForTesting()); +} + +// The whole point: the same cutoff in Hz must survive a sample-rate change, +// which the old fixed coefficient did not. +void test_hpf_cutoff_is_sample_rate_independent(void) { + ApuCore apu44; + apu44.init(44100); + apu44.reset(); + apu44.setHpfCutoffHz(90.0f); + + ApuCore apu22; + apu22.init(22050); + apu22.reset(); + apu22.setHpfCutoffHz(90.0f); + + // Different coefficients... + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9872591f, apu44.getHpfRForTesting()); + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9746804f, apu22.getHpfRForTesting()); + + // ...but the same corner frequency: fc = -ln(R) * fs / (2*pi). + const float fc44 = -logf(apu44.getHpfRForTesting()) * 44100.0f / 6.2831853f; + const float fc22 = -logf(apu22.getHpfRForTesting()) * 22050.0f / 6.2831853f; + TEST_ASSERT_FLOAT_WITHIN(1.0f, 90.0f, fc44); + TEST_ASSERT_FLOAT_WITHIN(1.0f, 90.0f, fc22); + + // For contrast, the legacy coefficient does NOT hold its corner: the same + // R = 0.995 is ~35.2 Hz at 44100 and ~17.6 Hz at 22050. + const float legacy44 = -logf(0.995f) * 44100.0f / 6.2831853f; + const float legacy22 = -logf(0.995f) * 22050.0f / 6.2831853f; + TEST_ASSERT_FLOAT_WITHIN(0.5f, 35.2f, legacy44); + TEST_ASSERT_FLOAT_WITHIN(0.5f, 17.6f, legacy22); +} + +// Re-initialising at a new rate must recompute R so the cutoff is preserved. +void test_hpf_init_recomputes_for_new_sample_rate(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setHpfCutoffHz(90.0f); + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9872591f, apu.getHpfRForTesting()); + + apu.init(22050); + TEST_ASSERT_FLOAT_WITHIN(0.0005f, 0.9746804f, apu.getHpfRForTesting()); + TEST_ASSERT_EQUAL_FLOAT(90.0f, apu.getHpfCutoffHzForTesting()); +} + +// Cutoff 0 (or negative) bypasses the filter entirely. +void test_hpf_zero_cutoff_disables_filter(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setHpfCutoffHz(0.0f); + TEST_ASSERT_FALSE(apu.getHpfEnabledForTesting()); + + apu.setHpfCutoffHz(90.0f); + TEST_ASSERT_TRUE(apu.getHpfEnabledForTesting()); + + apu.setHpfCutoffHz(-5.0f); + TEST_ASSERT_FALSE(apu.getHpfEnabledForTesting()); +} + +// Behavioural: the filter's job is removing DC, so measure DC. Bypass leaves +// the 12.5%-duty offset intact; every active corner removes it, and a higher +// corner removes it faster. Measured DC at 44100 Hz over the first 512 +// samples: bypass ~5900, 35 Hz ~1970, 90 Hz ~760, 240 Hz ~410. +void test_hpf_cutoff_changes_low_frequency_content(void) { + const int32_t dcBypass = measureHpfDc(0.0f); + const int32_t dc35 = measureHpfDc(35.0f); + const int32_t dc90 = measureHpfDc(90.0f); + const int32_t dc240 = measureHpfDc(240.0f); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(3000, dcBypass, + "a bypassed HPF must leave the duty-asymmetry DC intact"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dc35, dcBypass / 2, + "any active corner must remove most of the DC a bypass keeps"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dc90, dc35, + "a 90 Hz corner must settle the DC faster than 35 Hz"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dc240, dc90, + "a 240 Hz corner must settle the DC faster than 90 Hz"); +} + +// The untouched default must behave like its nominal corner (~35 Hz at +// 44100), not like a bypass. Guards the "legacy coefficient" path. +void test_hpf_legacy_default_still_blocks_dc(void) { + const int32_t dcLegacy = measureHpfDc(-1.0f); // never call the setter + const int32_t dcBypass = measureHpfDc(0.0f); + const int32_t dc35 = measureHpfDc(35.0f); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dcLegacy, dcBypass / 2, + "the default HPF must block DC, not pass it through"); + // R = 0.995 is ~35.2 Hz at 44100, so it must land next to the 35 Hz case. + TEST_ASSERT_INT32_WITHIN_MESSAGE(150, dc35, dcLegacy, + "the legacy coefficient must behave like its nominal ~35 Hz corner"); +} + +// reset() restores configuration as well as state, the same way it restores +// masterVolume and the bitcrush depth. +void test_hpf_reset_restores_legacy_coefficient(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setHpfCutoffHz(240.0f); + TEST_ASSERT_TRUE(apu.getHpfCutoffHzForTesting() > 0.0f); + + apu.reset(); + TEST_ASSERT_TRUE(apu.getHpfCutoffHzForTesting() < 0.0f); + TEST_ASSERT_EQUAL_FLOAT(0.995f, apu.getHpfRForTesting()); + TEST_ASSERT_EQUAL_INT32(32604, apu.getHpfRQ15ForTesting()); + TEST_ASSERT_TRUE(apu.getHpfEnabledForTesting()); +} + +// Absurd cutoffs must not produce a coefficient outside (0, 1), which would +// make the recursive filter unstable. +void test_hpf_extreme_cutoff_stays_stable(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setHpfCutoffHz(100000.0f); // far above Nyquist + TEST_ASSERT_TRUE(apu.getHpfRForTesting() >= 0.0f); + TEST_ASSERT_TRUE(apu.getHpfRForTesting() < 1.0f); + + apu.setHpfCutoffHz(0.001f); // effectively DC + TEST_ASSERT_TRUE(apu.getHpfRForTesting() > 0.0f); + TEST_ASSERT_TRUE(apu.getHpfRForTesting() < 1.0f); + + int16_t buffer[512] = {0}; + startLowPulseVoice(apu, buffer); + apu.generateSamples(buffer, 512); // must not blow up + for (int i = 0; i < 512; ++i) { + TEST_ASSERT_TRUE(buffer[i] >= -32768 && buffer[i] <= 32767); + } +} + +// ============================================================================= +// Hito 4 M11: configurable soft-clip +// ============================================================================= +// +// The summed mix passes through a shaping curve before the DC blocker. +// `Rational` (`S / (1 + |S|*MIXER_K)`) is the historical default and stays +// bit-exact, including the pre-fitted `audio_mixer_lut` on the integer path. +// `HardClip` is the interesting addition: the NES DAC saturates rather than +// compressing, and the analog HPF then filters the already-squared signal. + +// Fills the SFX pool with loud PULSE voices so the accumulated mix is driven +// well past full scale and the curves actually diverge. Returns how many +// voices started. +static int startLoudMix(ApuCore& apu, int16_t* scratch) { + int started = 0; + for (int i = 0; i < 4; ++i) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 220.0f + 55.0f * (float)i; + cmd.event.duration = 0.0f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.5f; + cmd.event.loop = true; + if (apu.submitCommand(cmd)) ++started; + } + apu.generateSamples(scratch, 512); + TEST_ASSERT_GREATER_THAN_INT(0, started); + return started; +} + +// Regime A: ONE voice at full volume, HPF bypassed. The mix peaks at +// MIXER_SCALE = 0.4, comfortably below unity, so no clamp ever engages and +// each curve's actual shape reaches the output. This is what pins the +// formulas and the amplitude scaling; a wrong constant moves these numbers a +// long way. +static int32_t measureCurvePeakBelowUnity(SoftClipMode mode) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setSoftClipMode(mode); + apu.setHpfCutoffHz(0.0f); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 220.0f; + cmd.event.duration = 0.0f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.5f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[2048] = {0}; + apu.generateSamples(buffer, 2048); // dispatch + settle the ADSR + return renderPeakMagnitude(apu, buffer, 2048); +} + +// Regime B: four loud voices with an asymmetric duty and the HPF running, so +// the mix is driven past unity and the curves diverge in how much they let +// through. Mean magnitude, because above unity the final int16 clamp flattens +// every curve to the same peak — the peak cannot tell them apart, the average +// can. +static void measureOverdrive(SoftClipMode mode, int32_t* meanAbs, int32_t* rails) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setSoftClipMode(mode); + apu.setHpfCutoffHz(90.0f); + + for (int i = 0; i < 4; ++i) { + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 220.0f + 55.0f * (float)i; + cmd.event.duration = 0.0f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.125f; + cmd.event.loop = true; + apu.submitCommand(cmd); + } + + int16_t buffer[2048] = {0}; + apu.generateSamples(buffer, 2048); + apu.generateSamples(buffer, 2048); + + int64_t total = 0; + int32_t railed = 0; + for (int i = 0; i < 2048; ++i) { + const int32_t mag = buffer[i] < 0 ? -(int32_t)buffer[i] : buffer[i]; + total += mag; + if (mag >= 32766) ++railed; + } + *meanAbs = (int32_t)(total / 2048); + *rails = railed; +} + +void test_softclip_default_is_rational(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::Rational, + (int)apu.getSoftClipModeForTesting()); +} + +void test_softclip_mode_round_trips(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setSoftClipMode(SoftClipMode::HardClip); + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::HardClip, + (int)apu.getSoftClipModeForTesting()); + apu.setSoftClipMode(SoftClipMode::Tanh); + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::Tanh, + (int)apu.getSoftClipModeForTesting()); + apu.setSoftClipMode(SoftClipMode::None); + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::None, + (int)apu.getSoftClipModeForTesting()); +} + +// reset() restores configuration as well as state, matching masterVolume, +// the bitcrush depth and the HPF corner. +void test_softclip_reset_restores_default(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setSoftClipMode(SoftClipMode::HardClip); + apu.reset(); + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::Rational, + (int)apu.getSoftClipModeForTesting()); +} + +// Below unity each curve reaches the output unaltered by any clamp, so the +// levels are a direct read-out of the formulas at S = MIXER_SCALE = 0.4: +// None 0.4 -> 0.4 * 32767 = 13107 +// Rational 0.4 / (1 + 0.4*0.5) -> 0.3333 * 32767 = 10922 +// Tanh 0.4 * (27+0.16)/(27+1.44) -> 0.3820 * 32767 = 12519 +// HardClip clamp(0.4) -> identical to None +// Tight tolerances on purpose: this is the test that catches a wrong +// coefficient or a wrong fixed-point scale. +void test_softclip_curve_shapes_below_unity(void) { + const int32_t none = measureCurvePeakBelowUnity(SoftClipMode::None); + const int32_t rational = measureCurvePeakBelowUnity(SoftClipMode::Rational); + const int32_t tanhMode = measureCurvePeakBelowUnity(SoftClipMode::Tanh); + const int32_t hard = measureCurvePeakBelowUnity(SoftClipMode::HardClip); + + TEST_ASSERT_INT32_WITHIN_MESSAGE(40, 13107, none, + "unshaped output must be S * 32767 with S = MIXER_SCALE"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(40, 10922, rational, + "the rational curve must match S/(1+|S|*MIXER_K)"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(40, 12519, tanhMode, + "the tanh approximation must match S*(27+S^2)/(27+9*S^2)"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(40, none, hard, + "below unity HardClip must be a no-op, identical to None"); +} + +// Above unity every curve gets flattened to the same peak by the final int16 +// clamp, so compare averages. HardClip caps the mix at 1.0 while the rational +// curve asymptotes toward 1/MIXER_K = 2.0, which makes HardClip the QUIETEST +// of the shaped modes on an overdriven mix — not the loudest. +void test_softclip_overdrive_ordering(void) { + int32_t meanNone, meanRational, meanTanh, meanHard; + int32_t railsNone, railsRational, railsTanh, railsHard; + measureOverdrive(SoftClipMode::None, &meanNone, &railsNone); + measureOverdrive(SoftClipMode::Rational, &meanRational, &railsRational); + measureOverdrive(SoftClipMode::Tanh, &meanTanh, &railsTanh); + measureOverdrive(SoftClipMode::HardClip, &meanHard, &railsHard); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(meanRational, meanNone, + "an unshaped overdriven mix must pass more level than the rational curve"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(meanTanh, meanRational, + "the rational curve must pass more level than the tanh approximation"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(meanHard, meanTanh, + "HardClip caps at 1.0, so it must pass the least level of the shaped modes"); + + // And the shaping has to actually bite: unshaped must be well clear of it. + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(2 * meanHard, meanNone, + "HardClip must bound an overdriven mix far below the unshaped one"); +} + +// Curve ordering is not the only observable: an unshaped mix overshoots into +// the final clamp far more often, because nothing bounded it beforehand. +void test_softclip_none_hits_the_rails_more_than_hardclip(void) { + int32_t meanNone, meanHard, railsNone, railsHard; + measureOverdrive(SoftClipMode::None, &meanNone, &railsNone); + measureOverdrive(SoftClipMode::HardClip, &meanHard, &railsHard); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(0, railsHard, + "an overdriven mix must reach full scale under HardClip"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(railsHard, railsNone, + "without shaping the mix must overshoot into the clamp more often"); +} + +// Every mode must stay inside int16 and never wrap, even fully overdriven. +void test_softclip_all_modes_stay_in_range(void) { + const SoftClipMode modes[4] = { + SoftClipMode::None, SoftClipMode::Rational, + SoftClipMode::Tanh, SoftClipMode::HardClip + }; + for (int m = 0; m < 4; ++m) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setSoftClipMode(modes[m]); + + int16_t buffer[1024] = {0}; + startLoudMix(apu, buffer); + apu.generateSamples(buffer, 1024); + for (int i = 0; i < 1024; ++i) { + TEST_ASSERT_TRUE(buffer[i] >= -32768 && buffer[i] <= 32767); + } + } +} + +// The curve runs BEFORE the DC blocker, which is what makes HardClip model the +// NES signal chain. Verify the ordering is observable: with the HPF active, +// HardClip's squared-off output still carries the duty-asymmetry DC removal, +// i.e. changing the curve does not bypass the filter. +void test_softclip_runs_before_the_dc_blocker(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setSoftClipMode(SoftClipMode::HardClip); + apu.setHpfCutoffHz(90.0f); + + int16_t buffer[512] = {0}; + startLowPulseVoice(apu, buffer); // 12.5% duty, strong DC + + int64_t total = 0; + for (int i = 0; i < 512; ++i) total += buffer[i]; + const int32_t dc = (int32_t)(total / 512); + const int32_t absDc = dc < 0 ? -dc : dc; + + TEST_ASSERT_LESS_THAN_INT32_MESSAGE(2000, absDc, + "the DC blocker must still run after the soft-clip curve"); +} + +// ============================================================================= +// Hito 4 M14: VoiceNesOptions +// ============================================================================= +// +// One struct that arms every per-voice NES opt-in at once, reachable two ways: +// `setVoiceNesOptions` directly, and `AudioCommandType::SET_NES_OPTIONS` +// through the queue for callers on the game thread. Same shape as M13's +// `setVoiceNesLength` / `TRIGGER_NES_LENGTH` pair — one semantic, two entry +// points, pinned by a test so they cannot drift apart. + +static VoiceNesOptions allOnOptions(void) { + VoiceNesOptions opts{}; + opts.lengthCounterEnabled = true; + opts.linearCounterEnabled = true; + opts.envelopeEnabled = true; + opts.sweepUnitEnabled = true; + opts.pulseDutyIndex = 2; + opts.noiseLutIndex = 9; + return opts; +} + +void test_nes_options_defaults_are_all_off(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + const VoiceNesOptions opts = apu.getVoiceNesOptions(0); + TEST_ASSERT_FALSE(opts.lengthCounterEnabled); + TEST_ASSERT_FALSE(opts.linearCounterEnabled); + TEST_ASSERT_FALSE(opts.envelopeEnabled); + TEST_ASSERT_FALSE(opts.sweepUnitEnabled); + TEST_ASSERT_EQUAL_UINT8(255, opts.pulseDutyIndex); + TEST_ASSERT_EQUAL_UINT8(255, opts.noiseLutIndex); +} + +void test_nes_options_round_trip(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesOptions(0, allOnOptions()); + const VoiceNesOptions read = apu.getVoiceNesOptions(0); + + TEST_ASSERT_TRUE(read.lengthCounterEnabled); + TEST_ASSERT_TRUE(read.linearCounterEnabled); + TEST_ASSERT_TRUE(read.envelopeEnabled); + TEST_ASSERT_TRUE(read.sweepUnitEnabled); + TEST_ASSERT_EQUAL_UINT8(2, read.pulseDutyIndex); + TEST_ASSERT_EQUAL_UINT8(9, read.noiseLutIndex); +} + +// The struct must write the REAL sub-unit fields, not a shadow copy that only +// the getter can see. Checked through the per-unit accessors. +void test_nes_options_arms_the_real_subunits(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesOptions(0, allOnOptions()); + + TEST_ASSERT_TRUE(apu.getVoiceNesChannelEnabledForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesLinearEnabledForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesEnvelopeEnabledForTesting(0)); + TEST_ASSERT_TRUE(apu.getVoiceNesSweepUnitEnabledForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(9, apu.getVoiceNesNoiseLutIndexForTesting(0)); +} + +// Clearing must actually disarm, not just leave the previous state behind. +void test_nes_options_disarms_everything(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + apu.setVoiceNesOptions(0, allOnOptions()); + apu.setVoiceNesOptions(0, VoiceNesOptions{}); + + TEST_ASSERT_FALSE(apu.getVoiceNesChannelEnabledForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesLinearEnabledForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesEnvelopeEnabledForTesting(0)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(0)); + TEST_ASSERT_EQUAL_UINT8(255, apu.getVoiceNesNoiseLutIndexForTesting(0)); +} + +// Out-of-range selector values must land on the documented sentinels, exactly +// as the dedicated setters already do. +void test_nes_options_invalid_indices_fall_back(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + VoiceNesOptions opts{}; + opts.pulseDutyIndex = 200; // valid range is 0..3 + opts.noiseLutIndex = 200; // valid range is 0..15 + apu.setVoiceNesOptions(0, opts); + + const VoiceNesOptions read = apu.getVoiceNesOptions(0); + TEST_ASSERT_EQUAL_UINT8(255, read.pulseDutyIndex); + TEST_ASSERT_EQUAL_UINT8(255, read.noiseLutIndex); +} + +// The queued command must land on exactly the same state as the direct call. +// If these ever diverge, a caller would get different results depending on +// which entry point they used. +void test_nes_options_command_matches_direct_setter(void) { + ApuCore direct; + direct.init(44100); + direct.reset(); + direct.setVoiceNesOptions(1, allOnOptions()); + + ApuCore queued; + queued.init(44100); + queued.reset(); + AudioCommand cmd{}; + cmd.type = AudioCommandType::SET_NES_OPTIONS; + cmd.channelIndex = 1; + cmd.nesOptions = allOnOptions(); + TEST_ASSERT_TRUE(queued.submitCommand(cmd)); + int16_t buffer[64] = {0}; + queued.generateSamples(buffer, 64); + + const VoiceNesOptions a = direct.getVoiceNesOptions(1); + const VoiceNesOptions b = queued.getVoiceNesOptions(1); + TEST_ASSERT_EQUAL_UINT8(a.lengthCounterEnabled, b.lengthCounterEnabled); + TEST_ASSERT_EQUAL_UINT8(a.linearCounterEnabled, b.linearCounterEnabled); + TEST_ASSERT_EQUAL_UINT8(a.envelopeEnabled, b.envelopeEnabled); + TEST_ASSERT_EQUAL_UINT8(a.sweepUnitEnabled, b.sweepUnitEnabled); + TEST_ASSERT_EQUAL_UINT8(a.pulseDutyIndex, b.pulseDutyIndex); + TEST_ASSERT_EQUAL_UINT8(a.noiseLutIndex, b.noiseLutIndex); + TEST_ASSERT_TRUE(queued.getVoiceNesSweepUnitEnabledForTesting(1)); +} + +void test_nes_options_command_ignores_bad_slot(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::SET_NES_OPTIONS; + cmd.channelIndex = (uint8_t)ApuCore::MAX_VOICES; // out of range + cmd.nesOptions = allOnOptions(); + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[64] = {0}; + apu.generateSamples(buffer, 64); // must not crash + + for (int s = 0; s < ApuCore::MAX_VOICES; ++s) { + TEST_ASSERT_FALSE(apu.getVoiceNesChannelEnabledForTesting(s)); + TEST_ASSERT_FALSE(apu.getVoiceNesSweepUnitEnabledForTesting(s)); + } +} + +// Selecting a noise LUT index through the struct must retune the voice +// immediately, the same as calling `setVoiceNesNoiseLutMode` would. +void test_nes_options_noise_lut_retunes_immediately(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + + VoiceNesOptions opts{}; + opts.noiseLutIndex = 15; + apu.setVoiceNesOptions(slot, opts); + + TEST_ASSERT_EQUAL_UINT32(nes_apu::nesNoisePeriodToSamples(15, 44100), + apu.getVoiceNoisePeriodForTesting(slot)); +} + +// M17's safe state clears the NES sub-units but deliberately leaves the +// waveform mode selectors alone. Reading the options back after a nesAccurate +// note must show exactly that split, so the two features stay consistent. +void test_nes_options_reflect_nes_safe_boundary(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + + int16_t buffer[256] = {0}; + const int slot = startLoopingNoiseVoice(apu, buffer); + apu.setVoiceNesOptions(slot, allOnOptions()); + + // Stop it first: `playPresetNote` returns the first ENABLED SFX slot, so + // with the old voice still running the new note lands elsewhere and the + // assertions below would read an untouched slot. + AudioCommand stop{}; + stop.type = AudioCommandType::STOP_CHANNEL; + stop.channelIndex = (uint8_t)slot; + TEST_ASSERT_TRUE(apu.submitCommand(stop)); + apu.generateSamples(buffer, 64); + // STOP_CHANNEL resets the voice, so re-arm the modes we want to observe. + apu.setVoiceNesOptions(slot, allOnOptions()); + + InstrumentPreset preset = INSTR_PULSE_LEAD; + preset.nesAccurate = true; + const int slot2 = playPresetNote(apu, buffer, WaveType::NOISE, preset); + TEST_ASSERT_EQUAL_INT(slot, slot2); + + const VoiceNesOptions after = apu.getVoiceNesOptions(slot); + // Sub-units: cleared by initNesSafe. + TEST_ASSERT_FALSE(after.lengthCounterEnabled); + TEST_ASSERT_FALSE(after.envelopeEnabled); + TEST_ASSERT_FALSE(after.sweepUnitEnabled); + TEST_ASSERT_FALSE(after.linearCounterEnabled); + // Waveform mode selectors: untouched. + TEST_ASSERT_EQUAL_UINT8(9, after.noiseLutIndex); + TEST_ASSERT_EQUAL_UINT8(2, after.pulseDutyIndex); +} + // ============================================================================= // Unity test runner // ============================================================================= @@ -2209,9 +5604,137 @@ int main(int argc, char** argv) { RUN_TEST(test_apu_core_pitch_envelope_precedes_single_sweep); RUN_TEST(test_apu_core_pitch_envelope_count_below_two_keeps_sweep); + // Hito 1: NES PULSE duty (M2) + TRIANGLE octave doubling (M16) + RUN_TEST(test_pulse_duty_nes_4modes); + RUN_TEST(test_pulse_duty_continuous_unchanged); + RUN_TEST(test_triangle_octave_up_default_off); + RUN_TEST(test_triangle_octave_up_doubles_freq); + RUN_TEST(test_triangle_octave_up_ignored_for_other_types); + + // Hito 2 M3: NES frame counter + RUN_TEST(test_frame_counter_initial_state); + RUN_TEST(test_frame_counter_mode_set_resets_timer); + RUN_TEST(test_frame_counter_quarter_clock_fires_at_240hz_mode0); + RUN_TEST(test_frame_counter_half_clock_fires_at_120hz_mode0); + RUN_TEST(test_frame_counter_mode1_no_irq); + RUN_TEST(test_frame_counter_irq_fires_at_60hz_mode0); + RUN_TEST(test_frame_counter_irq_inhibit_prevents_irq); + RUN_TEST(test_frame_counter_step_timing_uses_cpu_cycles); + + // Hito 2 M4: NES length counter + RUN_TEST(test_length_counter_initial_state); + RUN_TEST(test_length_counter_load); + RUN_TEST(test_length_counter_decrements_on_half_clock); + RUN_TEST(test_length_counter_silences_at_zero); + RUN_TEST(test_length_counter_halt_prevents_decrement); + RUN_TEST(test_length_counter_lut_values); + RUN_TEST(test_length_counter_channel_enable_off_forces_silence); + RUN_TEST(test_length_counter_does_not_affect_sine_saw); + + // Hito 2 M5: NES linear counter + RUN_TEST(test_linear_counter_initial_state); + RUN_TEST(test_linear_counter_load); + RUN_TEST(test_linear_counter_decrements_quarter_clock); + RUN_TEST(test_linear_counter_hold_with_control_set); + RUN_TEST(test_linear_counter_reload_flag_cleared_without_control); + RUN_TEST(test_triangle_gate_open_default); + RUN_TEST(test_triangle_gate_closes_when_length_zero); + RUN_TEST(test_triangle_gate_closes_when_linear_zero); + RUN_TEST(test_triangle_phase_does_not_advance_when_gated); + RUN_TEST(test_triangle_does_not_affect_sine_saw); + + // Hito 2 M6: NES sweep unit (PULSE) + RUN_TEST(test_sweep_initial_state); + RUN_TEST(test_sweep_setup_and_target_calculation); + RUN_TEST(test_sweep_negate_pulse1_ones_complement); + RUN_TEST(test_sweep_negate_pulse2_two_complement); + RUN_TEST(test_sweep_muting_target_overflow); + RUN_TEST(test_sweep_muting_current_period_too_low); + RUN_TEST(test_sweep_divider_reload); + RUN_TEST(test_sweep_does_not_affect_triangle_noise); + RUN_TEST(test_sweep_reload_flag_cleared); + RUN_TEST(test_sweep_muting_persists_when_disabled); + RUN_TEST(test_sweep_unarmed_voice_survives_frame_counter); + RUN_TEST(test_sweep_disarm_clears_muting); + RUN_TEST(test_sweep_register_writes_do_not_arm_the_unit); + + // Hito 2 M7: NES envelope unit (PULSE / NOISE) + RUN_TEST(test_envelope_initial_state); + RUN_TEST(test_envelope_setup_and_decay); + RUN_TEST(test_envelope_loop); + RUN_TEST(test_envelope_const_volume); + RUN_TEST(test_envelope_silence_at_zero); + RUN_TEST(test_envelope_start_flag_cleared_after_first_qclock); + RUN_TEST(test_envelope_does_not_affect_sine_saw_triangle); + RUN_TEST(test_envelope_volume_scaled_correctly); + RUN_TEST(test_envelope_coexists_with_length_counter); + RUN_TEST(test_envelope_loop_flag_shared_with_length_halt); + + // Hito 2 M13: TRIGGER_NES_LENGTH command + RUN_TEST(test_trigger_nes_length_loads_counter); + RUN_TEST(test_trigger_nes_length_arms_the_channel); + RUN_TEST(test_trigger_nes_length_raises_subunit_flags); + RUN_TEST(test_set_voice_nes_length_raises_subunit_flags); + RUN_TEST(test_trigger_nes_length_reloads_linear_counter); + RUN_TEST(test_trigger_nes_length_clamps_index); + RUN_TEST(test_trigger_nes_length_ignores_bad_slot); + RUN_TEST(test_trigger_nes_length_silences_voice_end_to_end); + + // Hito 2 M17: initNesSafe / nesAccurate + RUN_TEST(test_nes_safe_pulse); + RUN_TEST(test_nes_safe_triangle); + RUN_TEST(test_nes_safe_noise); + RUN_TEST(test_nes_safe_clears_previous_state); + RUN_TEST(test_nes_safe_off_by_default); + RUN_TEST(test_nes_safe_does_not_affect_sine_saw); + RUN_TEST(test_nes_safe_state_is_silent_when_armed); + + // Hito 3 M8: NES NOISE period LUT + RUN_TEST(test_noise_period_lut); + RUN_TEST(test_noise_lut_default_off); + RUN_TEST(test_noise_lut_sets_period_from_table); + RUN_TEST(test_noise_lut_applies_on_new_note); + RUN_TEST(test_noise_lut_off_restores_legacy_period); + RUN_TEST(test_noise_lut_invalid_index_falls_back_to_off); + RUN_TEST(test_noise_lut_ignored_for_non_noise); + RUN_TEST(test_noise_lut_survives_nes_safe_init); + RUN_TEST(test_noise_lut_is_sample_rate_independent); + + // Hito 4 M10: configurable master HPF + RUN_TEST(test_hpf_default_is_legacy_coefficient); + RUN_TEST(test_hpf_cutoff_computes_coefficient); + RUN_TEST(test_hpf_cutoff_is_sample_rate_independent); + RUN_TEST(test_hpf_init_recomputes_for_new_sample_rate); + RUN_TEST(test_hpf_zero_cutoff_disables_filter); + RUN_TEST(test_hpf_cutoff_changes_low_frequency_content); + RUN_TEST(test_hpf_legacy_default_still_blocks_dc); + RUN_TEST(test_hpf_reset_restores_legacy_coefficient); + RUN_TEST(test_hpf_extreme_cutoff_stays_stable); + + // Hito 4 M11: configurable soft-clip + RUN_TEST(test_softclip_default_is_rational); + RUN_TEST(test_softclip_mode_round_trips); + RUN_TEST(test_softclip_reset_restores_default); + RUN_TEST(test_softclip_curve_shapes_below_unity); + RUN_TEST(test_softclip_overdrive_ordering); + RUN_TEST(test_softclip_none_hits_the_rails_more_than_hardclip); + RUN_TEST(test_softclip_all_modes_stay_in_range); + RUN_TEST(test_softclip_runs_before_the_dc_blocker); + + // Hito 4 M14: VoiceNesOptions + RUN_TEST(test_nes_options_defaults_are_all_off); + RUN_TEST(test_nes_options_round_trip); + RUN_TEST(test_nes_options_arms_the_real_subunits); + RUN_TEST(test_nes_options_disarms_everything); + RUN_TEST(test_nes_options_invalid_indices_fall_back); + RUN_TEST(test_nes_options_command_matches_direct_setter); + RUN_TEST(test_nes_options_command_ignores_bad_slot); + RUN_TEST(test_nes_options_noise_lut_retunes_immediately); + RUN_TEST(test_nes_options_reflect_nes_safe_boundary); + // Integration tests RUN_TEST(test_apu_core_integration_full_pipeline); RUN_TEST(test_apu_core_integration_multiple_voices); return UNITY_END(); -} \ No newline at end of file +} diff --git a/test/test_apu_mirror.cpp b/test/test_apu_mirror.cpp index bd428e6..7e8ef1d 100644 --- a/test/test_apu_mirror.cpp +++ b/test/test_apu_mirror.cpp @@ -375,6 +375,7 @@ void test_apu_mirror_envelope_attack_rise(void) 0.0f, 0.0f, false, + false, 0.0f, }; @@ -408,7 +409,9 @@ void test_apu_mirror_envelope_attack_rise(void) void test_apu_mirror_lfsr_15bit_sequence(void) { - uint16_t state = 0x4000u; + // Hito 1 M9: the APU seeds the 15-bit LFSR to 0x7FFF (all-1s) on every + // voice trigger, matching the NES APU state after a write to $400F. + uint16_t state = 0x7FFFu; uint16_t expected[32] = {}; for (int i = 0; i < 32; ++i) { expected[i] = static_cast(state & 1u); @@ -456,7 +459,24 @@ void test_apu_mirror_lfsr_short_mode_differs(void) break; } } - TEST_ASSERT_TRUE_MESSAGE(differs, "noiseShortMode tap must diverge from normal LFSR"); + TEST_ASSERT_TRUE_MESSAGE(differs, "noiseLfsrShort tap must diverge from normal LFSR"); + + // Hito 0 (M15) rename coverage: an AudioChannel must expose both the new + // canonical field `noiseLfsrShort` and the deprecated alias + // `noiseShortMode`. After `reset()` both default to false (symmetric + // reset). Writing the new field reads back true, and the alias is read + // independently at the struct level (sync happens in + // ApuCore::initVoiceFromEvent, not in the struct). + AudioChannel ch{}; + ch.reset(); + TEST_ASSERT_FALSE(ch.noiseLfsrShort); + TEST_ASSERT_FALSE(ch.noiseShortMode); + + ch.noiseLfsrShort = true; + TEST_ASSERT_TRUE(ch.noiseLfsrShort); + // After a full init via ApuCore, both fields would be in sync; the + // struct itself does not auto-propagate, by design. + TEST_ASSERT_FALSE(ch.noiseShortMode); } void test_apu_mirror_music_gate_four_beats_at_120_bpm(void) @@ -1055,7 +1075,8 @@ void test_apu_mirror_melodic_zero_release_unchanged(void) 0.0f, // lfoFrequency 0.0f, // lfoDepth 0.0f, // lfoDelay - false, // noiseShortMode + false, // noiseLfsrShort + false, // noiseShortMode (deprecated alias) 0.0f, // dutySweep }; static const MusicNote kNote[] = { diff --git a/test/test_audio_music_types.cpp b/test/test_audio_music_types.cpp index 45f7a3f..dc63f35 100644 --- a/test/test_audio_music_types.cpp +++ b/test/test_audio_music_types.cpp @@ -185,7 +185,7 @@ void test_instr_pulse_lead_values(void) { TEST_ASSERT_EQUAL_FLOAT(5.0f, INSTR_PULSE_LEAD.lfoFrequency); TEST_ASSERT_EQUAL_FLOAT(0.025f, INSTR_PULSE_LEAD.lfoDepth); TEST_ASSERT_EQUAL_FLOAT(0.15f, INSTR_PULSE_LEAD.lfoDelay); - TEST_ASSERT_FALSE(INSTR_PULSE_LEAD.noiseShortMode); + TEST_ASSERT_FALSE(INSTR_PULSE_LEAD.noiseLfsrShort); TEST_ASSERT_EQUAL_FLOAT(0.0f, INSTR_PULSE_LEAD.dutySweep); TEST_ASSERT_EQUAL_FLOAT(0.0f, INSTR_PULSE_LEAD.pitchSweepEndHz); TEST_ASSERT_EQUAL_FLOAT(0.0f, INSTR_PULSE_LEAD.pitchSweepDurationSec); @@ -269,7 +269,7 @@ void test_instr_kick_values(void) { TEST_ASSERT_EQUAL_FLOAT(0.10f, INSTR_KICK.decayTime); TEST_ASSERT_EQUAL_FLOAT(0.00f, INSTR_KICK.sustainLevel); TEST_ASSERT_EQUAL(LfoTarget::NONE, INSTR_KICK.lfoTarget); - TEST_ASSERT_FALSE(INSTR_KICK.noiseShortMode); + TEST_ASSERT_FALSE(INSTR_KICK.noiseLfsrShort); } void test_instr_snare_values(void) { @@ -281,7 +281,7 @@ void test_instr_snare_values(void) { // LFSR clock = brighter snare crack. TEST_ASSERT_EQUAL(15, INSTR_SNARE.noisePeriod); TEST_ASSERT_EQUAL_FLOAT(0.08f, INSTR_SNARE.decayTime); - TEST_ASSERT_TRUE(INSTR_SNARE.noiseShortMode); // metallic timbre + TEST_ASSERT_TRUE(INSTR_SNARE.noiseLfsrShort); // metallic timbre } void test_instr_hihat_values(void) { @@ -292,7 +292,7 @@ void test_instr_hihat_values(void) { TEST_ASSERT_EQUAL(12, INSTR_HIHAT.noisePeriod); TEST_ASSERT_EQUAL_FLOAT(0.0005f, INSTR_HIHAT.attackTime); TEST_ASSERT_EQUAL_FLOAT(0.015f, INSTR_HIHAT.decayTime); - TEST_ASSERT_TRUE(INSTR_HIHAT.noiseShortMode); // metallic timbre + TEST_ASSERT_TRUE(INSTR_HIHAT.noiseLfsrShort); // metallic timbre } // ============================================================================ @@ -394,6 +394,114 @@ void test_music_track_with_voices(void) { TEST_ASSERT_EQUAL(&percTrack, percussionOnlyTrack.percussion); } +// ============================================================================ +// noiseLfsrShort / noiseShortMode alias — Hito 0 (M15) rename coverage +// ============================================================================ + +// Verifies that the InstrumentPreset struct exposes BOTH `noiseLfsrShort` +// (the new canonical name) and `noiseShortMode` (the deprecated alias) and +// that the built-in percussion presets keep them in sync. The two fields are +// independent C++ storage; the APU init path (ApuCore::initVoiceFromEvent) +// is the single point that copies the canonical value into the deprecated +// alias on AudioChannel. +void test_noise_lfsr_short_alias_consistency(void) +{ + // Built-in percussion presets must carry the same value in both fields + // (literal initialization in AudioMusicTypes.h). + TEST_ASSERT_TRUE(INSTR_SNARE.noiseLfsrShort); + TEST_ASSERT_TRUE(INSTR_SNARE.noiseShortMode); + TEST_ASSERT_TRUE(INSTR_HIHAT.noiseLfsrShort); + TEST_ASSERT_TRUE(INSTR_HIHAT.noiseShortMode); + + // Non-percussion presets: both must be false and equal. + TEST_ASSERT_FALSE(INSTR_PULSE_LEAD.noiseLfsrShort); + TEST_ASSERT_FALSE(INSTR_PULSE_LEAD.noiseShortMode); + TEST_ASSERT_EQUAL(INSTR_PULSE_LEAD.noiseLfsrShort, + INSTR_PULSE_LEAD.noiseShortMode); + + // User-written InstrumentPreset: the two fields are independent storage + // (no auto-sync at the struct level). Writing only one of them must + // leave the other at its previous value. This documents the design: + // the alias is kept in sync only via ApuCore::initVoiceFromEvent. + InstrumentPreset custom{}; + custom.noiseLfsrShort = true; + TEST_ASSERT_TRUE(custom.noiseLfsrShort); + TEST_ASSERT_FALSE(custom.noiseShortMode); // not auto-synced in the struct + + custom.noiseLfsrShort = false; + custom.noiseShortMode = true; + TEST_ASSERT_FALSE(custom.noiseLfsrShort); + TEST_ASSERT_TRUE(custom.noiseShortMode); +} + +// Verifies the default value (false) of both the new and deprecated fields. +void test_noise_lfsr_short_default_value(void) +{ + InstrumentPreset p{}; + TEST_ASSERT_FALSE(p.noiseLfsrShort); + TEST_ASSERT_FALSE(p.noiseShortMode); + TEST_ASSERT_EQUAL(p.noiseLfsrShort, p.noiseShortMode); +} + +// THE consumer contract. Both PixelRoot32-Game-Engine and PixelRoot32-Tool-Suite +// build InstrumentPreset with POSITIONAL aggregate initializers written against +// the published 1.0.1 field order, so any member inserted before the tail +// silently rebinds every one of those literals — or, since the displaced values +// are floats landing on a bool, breaks the build outright. +// +// The deprecated `noiseShortMode` alias therefore lives at the END of the +// struct, after `nesAccurate`. That is the whole reason for its position: it +// keeps positions 1..17 meaning exactly what they meant in 1.0.1. +// +// Distinctive value per trailing field on purpose — a one-slot shift is then +// unmissable instead of hiding behind a matching default. +void test_instrument_preset_positional_layout_matches_published_abi(void) +{ + constexpr InstrumentPreset legacy{ + 0.8f, 0.5f, 4, 0.25f, 7, + 0.01f, 0.02f, 0.75f, 0.03f, + LfoTarget::VOLUME, 6.0f, 0.4f, 0.05f, + true, // 14: noiseLfsrShort (named noiseShortMode in 1.0.1) + 0.125f, // 15: dutySweep + 880.0f, // 16: pitchSweepEndHz + 1.5f // 17: pitchSweepDurationSec + }; + + TEST_ASSERT_TRUE(legacy.noiseLfsrShort); + TEST_ASSERT_EQUAL_FLOAT(0.125f, legacy.dutySweep); + TEST_ASSERT_EQUAL_FLOAT(880.0f, legacy.pitchSweepEndHz); + TEST_ASSERT_EQUAL_FLOAT(1.5f, legacy.pitchSweepDurationSec); + + // Everything added after 1.0.1 must sit beyond the reach of such a literal. + TEST_ASSERT_FALSE(legacy.nesAccurate); + TEST_ASSERT_FALSE(legacy.noiseShortMode); +} + +// Guards the shipped presets against the alias silently falling out of sync. +// They initialize positionally, and the alias is now the LAST member, so it is +// easy to drop it and leave INSTR_SNARE / INSTR_HIHAT reporting false through +// the deprecated name while the canonical field says true. Consumers still read +// the alias off these constants, so the two must agree. +void test_shipped_presets_keep_deprecated_alias_in_sync(void) +{ + const InstrumentPreset presets[] = { + INSTR_PULSE_LEAD, INSTR_TRIANGLE_LEAD, INSTR_TRIANGLE_PAD, + INSTR_PULSE_PAD, INSTR_PULSE_HARMONY, INSTR_TRIANGLE_BASS, + INSTR_PULSE_BASS, INSTR_KICK, INSTR_SNARE, + INSTR_HIHAT + }; + + for (const InstrumentPreset& p : presets) { + TEST_ASSERT_EQUAL_MESSAGE(p.noiseLfsrShort, p.noiseShortMode, + "shipped preset: deprecated alias must match noiseLfsrShort"); + } + + // Pin the two that are actually true, so an all-false regression cannot + // satisfy the loop above. + TEST_ASSERT_TRUE(INSTR_SNARE.noiseShortMode); + TEST_ASSERT_TRUE(INSTR_HIHAT.noiseShortMode); +} + int main(int argc, char **argv) { UNITY_BEGIN(); @@ -441,6 +549,12 @@ int main(int argc, char **argv) { RUN_TEST(test_music_note_struct_fields); RUN_TEST(test_music_track_struct_fields); RUN_TEST(test_music_track_with_voices); - + + // Hito 0 (M15) rename coverage + RUN_TEST(test_noise_lfsr_short_alias_consistency); + RUN_TEST(test_noise_lfsr_short_default_value); + RUN_TEST(test_instrument_preset_positional_layout_matches_published_abi); + RUN_TEST(test_shipped_presets_keep_deprecated_alias_in_sync); + return UNITY_END(); } \ No newline at end of file diff --git a/test/test_library_link.cpp b/test/test_library_link.cpp new file mode 100644 index 0000000..969a51d --- /dev/null +++ b/test/test_library_link.cpp @@ -0,0 +1,130 @@ +/** + * @file test_library_link.cpp + * @brief Links the SHIPPED library target and calls the whole public API. + * + * Every other suite compiles `src/ApuCore.cpp` itself with `UNIT_TEST=1`. That + * means a definition accidentally left inside a `#if defined(UNIT_TEST)` block + * still resolves in all of them, and the gap stays invisible here while every + * consumer that links `PixelRoot32::APU` gets an undefined reference — which is + * exactly how the entire NES public API (Hitos 2-4) shipped unlinkable. + * + * So this target is deliberately different from the rest: it links the library + * instead of recompiling the source, and it must NOT define UNIT_TEST. + * + * It asserts almost nothing on purpose. Behaviour is covered by the other + * suites; what this one proves is that every public symbol EXISTS in the + * artifact consumers actually receive. The calls only have to be reachable at + * link time, so keep this file exhaustive rather than deep: when a new public + * method is added to ApuCore, add a call here. + */ + +#include +#include + +#include "pixelroot32/apu/ApuCore.h" +#include "pixelroot32/apu/AudioTypes.h" +#include "pixelroot32/apu/AudioMusicTypes.h" + +#if defined(UNIT_TEST) +#error "test_library_link must be compiled WITHOUT UNIT_TEST: it exists to link the shipped library" +#endif + +using namespace pixelroot32::audio; + +extern "C" void setUp(void) {} +extern "C" void tearDown(void) {} + +static void post_mix_noop(int16_t*, int, void*) {} + +// Transport, mixing and the output stage (Hito 4). +void test_link_core_and_output_stage(void) { + ApuCore apu; + apu.init(44100); + apu.reset(); + apu.setSequencerNoteLimit(16); + apu.setPostMixMono(post_mix_noop, nullptr); + apu.setHpfCutoffHz(90.0f); + apu.setSoftClipMode(SoftClipMode::Tanh); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event.type = WaveType::PULSE; + cmd.event.frequency = 440.0f; + cmd.event.duration = 0.05f; + cmd.event.volume = 1.0f; + cmd.event.duty = 0.5f; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[256]; + apu.generateSamples(buffer, 256); + + ApuCore::ProfileEntry entries[8]; + uint8_t count = 0; + apu.getAndResetProfileStats(entries, count); +} + +// Waveform mode selectors (Hito 1 M2, Hito 3 M8). +void test_link_waveform_mode_selectors(void) { + ApuCore apu; + apu.init(44100); + apu.setVoicePulseDutyMode(0, 2); + apu.setVoiceNesNoiseLutMode(0, 7); +} + +// The NES sub-units (Hito 2 M3-M7, Hito 2 M13). These are the symbols that +// were missing from the shipped library. +void test_link_nes_sub_units(void) { + ApuCore apu; + apu.init(44100); + + apu.setNesFrameCounterMode(0); + apu.setNesFrameCounterInhibit(true); + (void)apu.getNesFrameCounterIrq(); + + apu.setVoiceNesChannelEnabled(0, true); + apu.setVoiceNesLength(0, 5, false); + apu.setVoiceNesLinearCounter(0, 64, true); + apu.setVoiceNesLinearEnabled(0, true); + apu.setVoiceNesEnvelope(0, false, true, 12); + apu.setVoiceNesEnvelopeEnabled(0, true); + apu.setVoiceNesSweep(0, true, 2, false, 1, false); + apu.setVoiceNesSweepUnitEnabled(0, true); + apu.setVoiceNesTimer(0, 512); + + // tickNesFrameCounter is private but reached through the render loop, and + // it was missing from the library too — so render with the counter armed. + int16_t buffer[128]; + apu.generateSamples(buffer, 128); +} + +// The bundled per-voice options (Hito 4 M14) and their queued counterpart. +void test_link_nes_options(void) { + ApuCore apu; + apu.init(44100); + + VoiceNesOptions opts{}; + opts.lengthCounterEnabled = true; + opts.envelopeEnabled = true; + apu.setVoiceNesOptions(0, opts); + const VoiceNesOptions readBack = apu.getVoiceNesOptions(0); + TEST_ASSERT_TRUE(readBack.lengthCounterEnabled); + + AudioCommand cmd{}; + cmd.type = AudioCommandType::TRIGGER_NES_LENGTH; + cmd.channelIndex = 0; + cmd.nesLengthIndex = 3; + cmd.nesLengthHalt = false; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[128]; + apu.generateSamples(buffer, 128); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_link_core_and_output_stage); + RUN_TEST(test_link_waveform_mode_selectors); + RUN_TEST(test_link_nes_sub_units); + RUN_TEST(test_link_nes_options); + return UNITY_END(); +} diff --git a/test/test_q15_smoke.cpp b/test/test_q15_smoke.cpp index a1b8b77..50beff8 100644 --- a/test/test_q15_smoke.cpp +++ b/test/test_q15_smoke.cpp @@ -5,9 +5,13 @@ * * The extraction analysis found that no firmware or test build compiled the * Q15 branch at all. This suite closes that gap: it forces the integer path - * on the host and checks the behaviours that do not depend on exact gain - * staging (which intentionally differs from the float path by the documented - * -6 dB headroom pad). + * on the host and checks the behaviours the float path cannot cover. + * + * Since Hito 5 (M12) the gain staging is unified, so the absolute levels + * asserted here are the same numbers `test_apu_core` pins on the float path. + * That is deliberate: cross-path parity is the property under test, and the + * only way to check it from two separate binaries is to pin both to the + * closed-form value of the mixing chain. */ #include @@ -164,6 +168,226 @@ void test_q15_music_sequencer_ticks(void) { TEST_ASSERT_TRUE(apu.getMusicGlobalTick() > 0); } +// Hito 4 M10: the configurable HPF has a second implementation on this path, +// and test_apu_core only ever exercises the float one. Cover the Q15 branch +// here so a divergence between the two cannot pass unnoticed. +// Absolute DC of the first window after a 12.5%-duty note starts. The +// asymmetric duty carries a large DC offset, which is precisely what the +// filter removes, so bypass and any active corner separate by an order of +// magnitude rather than a handful of counts. +static int32_t measure_hpf_dc(float cutoffHz) { + apu.reset(); + apu.init(22050); + if (cutoffHz >= 0.0f) { + apu.setHpfCutoffHz(cutoffHz); + } + + AudioCommand cmd; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event = make_event(WaveType::PULSE, 110.0f, 0.0f); + cmd.event.duty = 0.125f; + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[512]; + apu.generateSamples(buffer, 512); + + int64_t total = 0; + for (int i = 0; i < 512; ++i) { + total += buffer[i]; + } + const int32_t dc = static_cast(total / 512); + return dc < 0 ? -dc : dc; +} + +void test_q15_hpf_default_is_legacy_coefficient(void) { + TEST_ASSERT_TRUE(apu.getHpfEnabledForTesting()); + TEST_ASSERT_EQUAL_INT32(32604, apu.getHpfRQ15ForTesting()); +} + +void test_q15_hpf_cutoff_recomputes_coefficient(void) { + // setUp() init()s at 22050, so exp(-2*pi*90/22050) = 0.9746804 + apu.setHpfCutoffHz(90.0f); + TEST_ASSERT_INT32_WITHIN(3, + (int32_t)(0.9746804f * 32768.0f + 0.5f), apu.getHpfRQ15ForTesting()); +} + +void test_q15_hpf_cutoff_changes_low_frequency_content(void) { + const int32_t dcBypass = measure_hpf_dc(0.0f); + const int32_t dc90 = measure_hpf_dc(90.0f); + const int32_t dc240 = measure_hpf_dc(240.0f); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(1000, dcBypass, + "Q15 path: a bypassed HPF must leave the duty-asymmetry DC intact"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dc90, dcBypass / 2, + "Q15 path: an active corner must remove most of the DC"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(dc240, dc90, + "Q15 path: a 240 Hz corner must settle the DC faster than 90 Hz"); +} + +// Hito 4 M11: the soft-clip curves have a second implementation on this path. +// `Rational` keeps the pre-fitted LUT; the other modes are computed from the +// accumulated sum, so a scaling mistake there would only show up here. +// One voice at full volume: the mix stays well below unity, so no clamp +// engages and each curve's shape reaches the output untouched. +static int32_t q15_curve_peak_below_unity(SoftClipMode mode) { + apu.reset(); + apu.init(22050); + apu.setSoftClipMode(mode); + apu.setHpfCutoffHz(0.0f); + + AudioCommand cmd; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event = make_event(WaveType::PULSE, 220.0f, 0.0f); + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + + int16_t buffer[2048]; + apu.generateSamples(buffer, 2048); + return peak_abs(buffer, 2048); +} + +// Overdrive regime: four loud voices push the mix past unity. Mean magnitude, +// not peak — above unity every curve is flattened to the same rail by the +// final clamp, so the peak cannot tell them apart but the average can. Same +// reasoning as `measureOverdrive` on the float side. +static int32_t q15_softclip_mean(SoftClipMode mode) { + apu.reset(); + apu.init(22050); + apu.setSoftClipMode(mode); + apu.setHpfCutoffHz(0.0f); // bypass the HPF so we measure the curve alone + + for (int i = 0; i < 4; ++i) { + AudioCommand cmd; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event = make_event(WaveType::PULSE, 220.0f + 55.0f * i, 0.0f); + cmd.event.loop = true; + apu.submitCommand(cmd); + } + + int16_t buffer[1024]; + apu.generateSamples(buffer, 1024); // dispatch + warm up + apu.generateSamples(buffer, 1024); + + int64_t total = 0; + for (int i = 0; i < 1024; ++i) { + total += (buffer[i] < 0) ? -(int32_t)buffer[i] : (int32_t)buffer[i]; + } + return static_cast(total / 1024); +} + +void test_q15_softclip_default_is_rational(void) { + TEST_ASSERT_EQUAL_INT((int)SoftClipMode::Rational, + (int)apu.getSoftClipModeForTesting()); +} + +void test_q15_softclip_rational_compresses_below_none(void) { + const int32_t meanNone = q15_softclip_mean(SoftClipMode::None); + const int32_t meanRational = q15_softclip_mean(SoftClipMode::Rational); + + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(0, meanRational, + "Q15 path: the rational curve must still produce signal"); + TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(meanRational, meanNone, + "Q15 path: an overdriven mix must be louder unshaped than compressed"); +} + +// Hito 5 M12: pins the unified gain staging. MIXER_SCALE is applied exactly +// once — the summation feeds `audio_mixer_lut` the RAW channel sum, which is +// the convention the table was fitted to (`x = sum * 1.6 / 131072`, so its +// endpoints are four full-scale channels at S = 1.6, LUT[0] = -29126). +// +// One voice at full volume gives S = MIXER_SCALE = 0.4, below unity, so no +// clamp engages and each curve's shape reaches the output untouched. These +// are the SAME numbers `test_softclip_curve_shapes_below_unity` pins on the +// float path — that identity is the point of the milestone: +// None 0.4 -> 0.4 * 32767 = 13107 +// Rational LUT(0.4) = 0.4/(1 + 0.4*0.5) -> 0.3333 * 32767 = 10922 +// Tanh 0.4*(27+0.16)/(27+1.44) -> 0.3820 * 32767 = 12519 +// HardClip clamp(0.4) = no-op -> identical to None +// Absolute values on purpose: a reintroduced pad or a second MIXER_SCALE +// moves them by multiples, which relational assertions would not catch. +void test_q15_softclip_curve_shapes_match_the_float_path(void) { + TEST_ASSERT_INT32_WITHIN_MESSAGE(60, 13107, + q15_curve_peak_below_unity(SoftClipMode::None), + "Q15 path: unshaped level must be S * 32767 with S = MIXER_SCALE"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(60, 10922, + q15_curve_peak_below_unity(SoftClipMode::Rational), + "Q15 path: the rational curve must match S/(1+|S|*MIXER_K)"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(60, 12519, + q15_curve_peak_below_unity(SoftClipMode::Tanh), + "Q15 path: the tanh approximation must match S*(27+S^2)/(27+9*S^2)"); + TEST_ASSERT_INT32_WITHIN_MESSAGE(60, 13107, + q15_curve_peak_below_unity(SoftClipMode::HardClip), + "Q15 path: below unity HardClip must be a no-op, identical to None"); +} + +// Hito 5 M12: the `>>1` pad used to keep the output below half scale, so the +// final int16 cast could never overflow. Without it an unshaped four-voice +// mix reaches S = 1.6 (52428 in Q15) and the cast wraps unless the path +// clamps explicitly, exactly as the float branch does before its own cast. +// +// A wrapped output is unmistakable: 52428 becomes -13108, so the extremes +// collapse to ~+-13107 instead of pinning both rails. Checked as min/max +// rather than through `peak_abs`, whose int16_t return cannot represent the +// magnitude of the negative rail. +void test_q15_full_scale_mix_saturates_without_wrapping(void) { + apu.reset(); + apu.init(22050); + apu.setSoftClipMode(SoftClipMode::None); + apu.setHpfCutoffHz(0.0f); // bypass: the HPF has its own clamp + + for (int i = 0; i < 4; ++i) { + AudioCommand cmd; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event = make_event(WaveType::PULSE, 220.0f, 0.0f); + cmd.event.loop = true; + TEST_ASSERT_TRUE(apu.submitCommand(cmd)); + } + + int16_t buffer[1024]; + apu.generateSamples(buffer, 1024); // dispatch + settle the ADSR + apu.generateSamples(buffer, 1024); + + int32_t lowest = 32767; + int32_t highest = -32768; + for (int i = 0; i < 1024; ++i) { + if (buffer[i] < lowest) lowest = buffer[i]; + if (buffer[i] > highest) highest = buffer[i]; + } + + TEST_ASSERT_EQUAL_INT32_MESSAGE(32767, highest, + "Q15 path: an over-unity mix must saturate at the top rail, not wrap"); + TEST_ASSERT_EQUAL_INT32_MESSAGE(-32768, lowest, + "Q15 path: an over-unity mix must saturate at the bottom rail too"); +} + +void test_q15_softclip_all_modes_stay_in_range(void) { + const SoftClipMode modes[4] = { + SoftClipMode::None, SoftClipMode::Rational, + SoftClipMode::Tanh, SoftClipMode::HardClip + }; + for (int m = 0; m < 4; ++m) { + apu.reset(); + apu.init(22050); + apu.setSoftClipMode(modes[m]); + + for (int i = 0; i < 4; ++i) { + AudioCommand cmd; + cmd.type = AudioCommandType::PLAY_EVENT; + cmd.event = make_event(WaveType::PULSE, 220.0f + 55.0f * i, 0.0f); + cmd.event.loop = true; + apu.submitCommand(cmd); + } + + int16_t buffer[1024]; + apu.generateSamples(buffer, 1024); + apu.generateSamples(buffer, 1024); + for (int i = 0; i < 1024; ++i) { + TEST_ASSERT_TRUE(buffer[i] >= -32768 && buffer[i] <= 32767); + } + } +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_q15_pulse_produces_audio); @@ -173,5 +397,13 @@ int main(int, char**) { RUN_TEST(test_q15_master_volume_scales_before_compressor); RUN_TEST(test_q15_envelope_reaches_release_and_voice_ends); RUN_TEST(test_q15_music_sequencer_ticks); + RUN_TEST(test_q15_hpf_default_is_legacy_coefficient); + RUN_TEST(test_q15_hpf_cutoff_recomputes_coefficient); + RUN_TEST(test_q15_hpf_cutoff_changes_low_frequency_content); + RUN_TEST(test_q15_softclip_default_is_rational); + RUN_TEST(test_q15_softclip_rational_compresses_below_none); + RUN_TEST(test_q15_softclip_curve_shapes_match_the_float_path); + RUN_TEST(test_q15_full_scale_mix_saturates_without_wrapping); + RUN_TEST(test_q15_softclip_all_modes_stay_in_range); return UNITY_END(); } diff --git a/tools/loudness_probe/README.md b/tools/loudness_probe/README.md new file mode 100644 index 0000000..51a28b3 --- /dev/null +++ b/tools/loudness_probe/README.md @@ -0,0 +1,82 @@ +# loudness_probe — calibración de la ruta Q15 (Hito 5 / M12) + +Renderiza un track real del `music-demo` por las **dos** rutas de la APU y +compara los niveles. + +La ruta se elige en tiempo de compilación con `PR32_APU_HAS_FPU` +(`ApuConfig.h`), que es una definición común. Así que la aritmética entera +exacta que ejecuta el ESP32-C3 se puede correr acá, en el escritorio: **no hace +falta el hardware para la parte que importa.** + +```bash +python tools/loudness_probe/run.py +python tools/loudness_probe/run.py --track blaster_ridge --volume 0.8 +python tools/loudness_probe/run.py --wav-dir out/ # para el A/B auditivo +``` + +Tracks: `moonwell_hymn`, `lightworld_march`, `blaster_ridge`, `coinleaf_grove`. + +Requiere un compilador C++17 en el PATH y `PixelRoot32-Game-Engine` como +checkout hermano (de ahí salen los assets). + +--- + +## Cómo leer el resultado + +| Métrica | Para qué sirve | +|---|---| +| **rms ratio** | **La medida de loudness.** Es lo que M12 arregló: antes daba ~0.225. | +| peak ratio | Informativo. Es un estadístico de **una sola muestra**. | +| rail samples | Muestras clavadas en ±32767. **Tiene que ser 0.** | +| peak dBFS | Headroom que queda. | + +### Por qué el pico no es un criterio + +Las dos rutas nunca fueron bit-idénticas. La float evalúa la curva del +compresor de forma continua; la Q15 la busca en `audio_mixer_lut`: 1025 +entradas indexadas por `(sum + 131072) >> 8`, **sin interpolación** — unos 102 +counts de salida por escalón cerca de cero. + +Sobre material real de varias voces eso da unos puntos porcentuales de +diferencia. Medido sobre los cuatro tracks del demo: + +| track | rms ratio | peak ratio | +|---|---|---| +| moonwell_hymn | 1.013 | 1.145 | +| lightworld_march | 1.064 | 1.066 | +| blaster_ridge | **0.990** | 1.158 | +| coinleaf_grove | 1.094 | 1.117 | + +**Lo que distingue cuantización de un bug de ganancia es la dirección.** El RMS +cae a los dos lados del 1.0 (0.990 a 1.094); un error de ganancia sería +unidireccional y constante. El pico, en cambio, sale siempre alto: es el máximo +sobre cientos de miles de muestras, así que engancha sistemáticamente el lado +donde la cuantización del LUT redondea para arriba. + +El escalonado de ganancia exacto está fijado aparte y bit-exacto por +`test_q15_softclip_curve_shapes_match_the_float_path`. Este harness no lo +reemplaza: mide el comportamiento sobre material real, que es otra pregunta. + +--- + +## El procedimiento completo + +**Fase 1 — objetiva (esto).** Correr `run.py` sobre los cuatro tracks. Buscás: + +- `rms ratio` cerca de 1.0 → la paridad del M12 se sostiene. +- `rail samples` en 0 en las dos rutas → no clippea. + +Si clippea, bajá `--volume` y repetí hasta llegar a 0. Ese valor es el master +nuevo, y lo obtuviste midiendo en vez de adivinando. + +**Fase 2 — subjetiva.** `--wav-dir out/` deja `_float.wav` y +`_q15.wav`. Escuchalos uno detrás del otro: tienen que sonar **al mismo +nivel**. Es mucho más fácil juzgar paridad entre dos cosas que loudness +absoluto. + +**Fase 3 — hardware.** Recién ahora, y sabiendo que el mixer ya está +verificado. En el C3 lo que estás evaluando es el amplificador y el parlante: +distorsión en los picos y si el volumen del sistema necesita ajuste. + +El orden importa. Si arrancás por la fase 3 y algo suena mal, no vas a poder +distinguir si es el mixer, el amp o el parlante. diff --git a/tools/loudness_probe/loudness_probe.cpp b/tools/loudness_probe/loudness_probe.cpp new file mode 100644 index 0000000..f099bb5 --- /dev/null +++ b/tools/loudness_probe/loudness_probe.cpp @@ -0,0 +1,217 @@ +/** + * @file loudness_probe.cpp + * @brief Renders a real music-demo track through the APU and reports the + * output level, so the float and Q15 paths can be compared objectively. + * + * Why this exists + * --------------- + * Hito 5 (M12) unified the loudness of the integer/Q15 render path with the + * float one. Q15 is what FPU-less targets run — notably the ESP32-C3, which is + * the board `examples/music-demo -e esp32c3` builds for. The change makes that + * path roughly 4.4x louder, so it has to be checked for clipping and for + * parity with the float path. + * + * The render path is selected at COMPILE time by `PR32_APU_HAS_FPU` + * (ApuConfig.h), which is a plain compile definition. So the exact integer + * arithmetic the C3 executes can be run right here on the host — no hardware + * needed for the part that actually matters. Build this file twice, once + * normally and once with `-DPR32_APU_HAS_FPU=0`, and compare. + * + * What "correct" looks like after M12: peak and RMS should MATCH between the + * two builds (that parity is the point of the milestone), and neither should + * be pinning samples at the rails. + * + * `run.py` next to this file builds both variants and prints the comparison. + * + * The numbers here describe the mixer, not the hardware. A real C3 still has + * its own amplifier and speaker; this tells you whether the signal you are + * feeding them is right before you go listen to it. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "pixelroot32/apu/ApuCore.h" +#include "pixelroot32/apu/AudioTypes.h" +#include "pixelroot32/apu/AudioMusicTypes.h" + +// The music-demo assets. They are generated headers that depend only on the +// APU types (through the engine's re-export shims), so they can be replayed +// outside the engine and outside SDL. +#include "assets/blaster_ridge.h" +#include "assets/coinleaf_grove.h" +#include "assets/lightworld_march.h" +#include "assets/moonwell_hymn.h" + +using namespace pixelroot32::audio; + +namespace { + +struct Track { + const char* name; + const MusicTrack* track; + float bpm; +}; + +const Track kTracks[] = { + {"moonwell_hymn", &musicdemo::moonwell_hymn::kTrack, musicdemo::moonwell_hymn::kBpm}, + {"lightworld_march", &musicdemo::lightworld_march::kTrack, musicdemo::lightworld_march::kBpm}, + {"blaster_ridge", &musicdemo::blaster_ridge::kTrack, musicdemo::blaster_ridge::kBpm}, + {"coinleaf_grove", &musicdemo::coinleaf_grove::kTrack, musicdemo::coinleaf_grove::kBpm}, +}; +const int kTrackCount = int(sizeof(kTracks) / sizeof(kTracks[0])); + +struct Stats { + int32_t peak = 0; + int64_t sumSq = 0; + int64_t rails = 0; // samples sitting on +32767 / -32768 + int64_t count = 0; + std::vector* wav = nullptr; +}; + +// Runs on the exact stream the hardware would receive: after mixing, the +// soft-clip curve, the DC blocker and the bitcrush. +void capture(int16_t* mono, int length, void* user) { + Stats* s = static_cast(user); + for (int i = 0; i < length; ++i) { + const int32_t v = mono[i]; + const int32_t mag = (v < 0) ? -v : v; + if (mag > s->peak) s->peak = mag; + s->sumSq += int64_t(v) * int64_t(v); + if (v >= 32767 || v <= -32768) ++s->rails; + ++s->count; + } + if (s->wav) s->wav->insert(s->wav->end(), mono, mono + length); +} + +double dbfs(double linear) { + if (linear <= 0.0) return -INFINITY; + return 20.0 * std::log10(linear / 32768.0); +} + +bool write_wav(const std::string& path, const std::vector& pcm, int rate) { + FILE* f = std::fopen(path.c_str(), "wb"); + if (!f) return false; + const uint32_t dataBytes = uint32_t(pcm.size() * sizeof(int16_t)); + const uint32_t byteRate = uint32_t(rate) * 2u; + auto u32 = [&](uint32_t v) { std::fwrite(&v, 4, 1, f); }; + auto u16 = [&](uint16_t v) { std::fwrite(&v, 2, 1, f); }; + std::fwrite("RIFF", 1, 4, f); u32(36u + dataBytes); std::fwrite("WAVE", 1, 4, f); + std::fwrite("fmt ", 1, 4, f); u32(16u); u16(1); u16(1); + u32(uint32_t(rate)); u32(byteRate); u16(2); u16(16); + std::fwrite("data", 1, 4, f); u32(dataBytes); + std::fwrite(pcm.data(), 1, dataBytes, f); + std::fclose(f); + return true; +} + +[[noreturn]] void usage(const char* argv0) { + std::fprintf(stderr, + "usage: %s [--track NAME] [--rate HZ] [--seconds N] [--volume F] [--wav PATH]\n" + " --track one of: moonwell_hymn, lightworld_march, blaster_ridge,\n" + " coinleaf_grove (default moonwell_hymn)\n" + " --rate sample rate, default 44100 (what DefaultAudioScheduler uses)\n" + " --seconds render duration, default 20\n" + " --volume master volume 0..1, default 1.0 — this is the knob you are\n" + " calibrating\n" + " --wav also write the rendered audio here, for the listening A/B\n", argv0); + std::exit(2); +} + +} // namespace + +int main(int argc, char** argv) { + std::string trackName = "moonwell_hymn"; + std::string wavPath; + int rate = 44100; + double seconds = 20.0; + float volume = 1.0f; + + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + const bool hasNext = (i + 1 < argc); + if (a == "--track" && hasNext) trackName = argv[++i]; + else if (a == "--rate" && hasNext) rate = std::atoi(argv[++i]); + else if (a == "--seconds" && hasNext) seconds = std::atof(argv[++i]); + else if (a == "--volume" && hasNext) volume = float(std::atof(argv[++i])); + else if (a == "--wav" && hasNext) wavPath = argv[++i]; + else usage(argv[0]); + } + + const Track* chosen = nullptr; + for (int i = 0; i < kTrackCount; ++i) { + if (trackName == kTracks[i].name) { chosen = &kTracks[i]; break; } + } + if (!chosen || rate <= 0 || seconds <= 0.0) usage(argv[0]); + + std::vector wav; + Stats stats; + if (!wavPath.empty()) { + wav.reserve(size_t(seconds * rate)); + stats.wav = &wav; + } + + ApuCore apu; + apu.init(rate); + apu.reset(); + apu.setPostMixMono(&capture, &stats); + + AudioCommand bpm{}; + bpm.type = AudioCommandType::MUSIC_SET_BPM; + bpm.bpm = chosen->bpm; + apu.submitCommand(bpm); + + AudioCommand vol{}; + vol.type = AudioCommandType::SET_MASTER_VOLUME; + vol.volume = volume; + apu.submitCommand(vol); + + AudioCommand play{}; + play.type = AudioCommandType::MUSIC_PLAY; + play.track = chosen->track; + play.subTrackCount = 0; // sub-voices are wired inside MusicTrack itself + apu.submitCommand(play); + + // Block size is irrelevant to the result — the mixer is sample-by-sample — + // but a realistic one keeps the sequencer's per-block work representative. + const int kBlock = 512; + const long long total = (long long)(seconds * rate); + std::vector buffer(kBlock); + for (long long done = 0; done < total; done += kBlock) { + const int n = int((total - done < kBlock) ? (total - done) : kBlock); + apu.generateSamples(buffer.data(), n); + } + + const double rms = (stats.count > 0) + ? std::sqrt(double(stats.sumSq) / double(stats.count)) : 0.0; + const double railsPpm = (stats.count > 0) + ? (double(stats.rails) * 1e6 / double(stats.count)) : 0.0; + +#if PR32_APU_HAS_FPU + const char* pathName = "float"; +#else + const char* pathName = "q15"; +#endif + + // Machine-readable on purpose: run.py parses this. + std::printf("path=%s track=%s rate=%d seconds=%.1f volume=%.3f " + "samples=%lld peak=%d peak_dbfs=%.2f rms=%.1f rms_dbfs=%.2f " + "rails=%lld rails_ppm=%.1f\n", + pathName, chosen->name, rate, seconds, double(volume), + (long long)stats.count, stats.peak, dbfs(stats.peak), + rms, dbfs(rms), (long long)stats.rails, railsPpm); + + if (!wavPath.empty()) { + if (!write_wav(wavPath, wav, rate)) { + std::fprintf(stderr, "error: could not write %s\n", wavPath.c_str()); + return 1; + } + std::fprintf(stderr, "wrote %s (%zu samples)\n", wavPath.c_str(), wav.size()); + } + return 0; +} diff --git a/tools/loudness_probe/run.py b/tools/loudness_probe/run.py new file mode 100644 index 0000000..97f99f0 --- /dev/null +++ b/tools/loudness_probe/run.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Phase 1 of the Hito 5 loudness calibration: float vs Q15, side by side. + +Builds loudness_probe.cpp twice from the same source -- once normally (float +path) and once with -DPR32_APU_HAS_FPU=0 (the integer path an ESP32-C3 runs) -- +renders the same music-demo track through both, and compares the levels. + +After Hito 5 (M12) the two paths should render at the SAME level. That parity +is the milestone's contract, so the ratios below are the actual test: peak and +RMS ratios near 1.00, and no samples pinned at the rails. + + python tools/loudness_probe/run.py + python tools/loudness_probe/run.py --track blaster_ridge --volume 0.8 + python tools/loudness_probe/run.py --wav-dir out/ # for the listening A/B + +Needs a C++17 compiler on PATH (g++ or clang++) and a checkout of +PixelRoot32-Game-Engine as a sibling of this repo, for the demo assets. +""" + +import argparse +import pathlib +import re +import shutil +import subprocess +import sys + +HERE = pathlib.Path(__file__).resolve().parent +APU = HERE.parent.parent +WORKSPACE = APU.parent +ENGINE = (WORKSPACE / "PixelRoot32-Game-Samples" / "lib" / "PixelRoot32-Game-Engine") +ASSETS = ENGINE / "examples" / "music-demo" / "src" + +VARIANTS = [("float", []), ("q15", ["-DPR32_APU_HAS_FPU=0"])] + + +def find_compiler(): + for name in ("g++", "clang++", "c++"): + found = shutil.which(name) + if found: + return found + sys.exit("error: no C++ compiler on PATH (tried g++, clang++, c++)") + + +def build(cxx, outdir, label, extra_defs): + exe = outdir / f"loudness_probe_{label}.exe" + cmd = [cxx, "-std=c++17", "-O2", "-DNOMINMAX", + f"-I{APU / 'include'}", + f"-I{ENGINE / 'include'}", # the