Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ build/
*.gcno
compile_commands.json
.cache/
.atl/
tools/loudness_probe/build/
21 changes: 20 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
4 changes: 3 additions & 1 deletion docs/ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 84 additions & 0 deletions docs/CHANGELOG.md

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions include/pixelroot32/apu/ApuConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>(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<uint16_t>(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<double>(kNesNoisePeriodLutNtsc[index])
* static_cast<double>(sampleRate) / kNesCpuHzNtsc;
if (samples < 1.0) return 1u;
return static_cast<uint32_t>(samples + 0.5);
}
} // namespace nes_apu

} // namespace pixelroot32::audio
Loading
Loading