diff --git a/api/audio.md b/api/audio.md index cfb6ab9..0c957b8 100644 --- a/api/audio.md +++ b/api/audio.md @@ -24,7 +24,7 @@ The Audio module provides a **NES-inspired** synthesis stack (Pulse, Triangle, N - `SINE`: Band-limited sine via LUT. - `SAW`: Sawtooth from a linear phase ramp. -All `WaveType` values share the same **`MAX_VOICES`** pool. Under contention the implementation may steal a voice (shortest remaining time) to make room for a new event. Internally, `VoiceType` mirrors these for allocation logic. +All `WaveType` values share the same **`MAX_VOICES`** pool. Under contention the implementation may steal a voice (lowest steal score; looping SFX score 0) to make room for a new event. Internally, `VoiceType` mirrors these for allocation logic. ### Predefined Instrument Presets @@ -49,13 +49,13 @@ evt.duty = 0.5f; audio.playEvent(evt); ``` -Sweep example (pulse or triangle only; ignored for `NOISE`): +Sweep example (melodic or noise period sweep): ```cpp AudioEvent sweep{}; sweep.type = WaveType::PULSE; sweep.frequency = 2000.0f; // start Hz -sweep.sweepEndHz = 400.0f; // end Hz +sweep.sweepEndHz = 400.0f; // end Hz (melodic) or target noise clock Hz for NOISE sweep.sweepDurationSec = 0.15f; // active when both this and sweepEndHz > 0 sweep.duration = 0.25f; sweep.volume = 0.7f; @@ -63,6 +63,21 @@ sweep.duty = 0.5f; audio.playEvent(sweep); ``` +Looping SFX (motor, ambience) — stop explicitly when done: + +```cpp +AudioEvent motor{}; +motor.type = WaveType::TRIANGLE; +motor.frequency = 110.0f; +motor.loop = true; +motor.volume = 0.35f; +motor.preset = &INSTR_TRIANGLE_BASS; +audio.playEvent(motor); +// Later: STOP_CHANNEL on the voice slot, or stop all SFX voices when leaving the scene. +``` + +Under SFX pool saturation, **looping voices are preferred steal targets** (steal score 0) so new one-shots are not starved. + ### Playing Music ```cpp diff --git a/api/generated/audio/ApuCore.md b/api/generated/audio/ApuCore.md index db9f0c2..68ff1ba 100644 --- a/api/generated/audio/ApuCore.md +++ b/api/generated/audio/ApuCore.md @@ -8,8 +8,10 @@ Shared NES-style APU core used by every AudioScheduler. -Owns the 4 channels (2x PULSE, 1x TRIANGLE, 1x NOISE), the SPSC command -queue and the music sequencer. Platform-specific schedulers +Owns an eight-voice pool (MAX_VOICES = 8), the SPSC command queue and the +music sequencer. Voice slots 0–3 are reserved for sequencer tracks +(trackIdx maps 1:1 to slot); slots 4–7 are reserved for PLAY_EVENT / SFX +and sequencer percussion hits (shared subpool, steal policy confined to 4–7). Platform-specific schedulers (DefaultAudioScheduler, ESP32AudioScheduler, NativeAudioScheduler) are thin orchestrators that decide *when* generateSamples() runs; all synthesis, mixing and sequencing lives here to eliminate the three-way @@ -165,4 +167,124 @@ Reads and clears all profile entries. ### `size_t countEnabledVoicesForTesting() const` +**Description:** + +Test-only: counts voices with enabled==true. + +**Returns:** Active voice count in [0, MAX_VOICES]. + +::: tip +Available only when UNIT_TEST is defined (native_test). Not for game code. +::: + ### `size_t getSequencerMainNoteIndexForTesting() const` + +**Description:** + +Test-only: main music track note index after the last sequencer run. + +**Returns:** Index into track 0's note array. + +::: tip +Available only when UNIT_TEST is defined (native_test). Not for game code. +::: + +### `bool isVoiceEnabledForTesting(int slot) const` + +**Description:** + +Test-only: reports whether a voice slot is currently synthesizing. + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns false. + +**Returns:** true if voices[slot].enabled. + +::: tip +Available only when UNIT_TEST is defined (native_test). Not for game code. +::: + +### `bool isMusicTrackVoiceActiveForTesting(size_t track_index) const` + +**Description:** + +Test-only: reports whether a music track has an active melodic gate. + +**Parameters:** + +- `track_index`: Sequencer track [0, MAX_MUSIC_TRACKS); out-of-range returns false. + +**Returns:** true after a melodic note until Rest note-off or lifecycle reset. + +::: tip +Available only when UNIT_TEST is defined (native_test). Not for game code. +::: + +### `uint32_t getVoiceNoisePeriodForTesting(int slot) const` + +**Description:** + +Test-only: NOISE LFSR period in samples for a voice slot. + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns 0. + +**Returns:** NOISE LFSR period in samples if slot is valid, 0 otherwise. + +### `uint64_t getVoiceRemainingSamplesForTesting(int slot) const` + +**Description:** + +Test-only: remaining sample gate for a voice slot. + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns 0. + +**Returns:** remaining samples if slot is valid, 0 otherwise. + +### `bool isVoiceLoopForTesting(int slot) const` + +**Description:** + +Test-only: whether a voice slot is in continuous loop mode. + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns false. + +**Returns:** true if voice is in loop mode. + +### `float getVoiceFrequencyForTesting(int slot) const` + +**Description:** + +Test-only: current voice frequency in Hz (melodic / noise clock). + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns 0. + +**Returns:** Frequency in Hz if slot is valid, 0 otherwise. + +### `float getVoiceDutyCycleForTesting(int slot) const` + +**Description:** + +Test-only: current PULSE duty cycle [0,1]. + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns 0. + +### `float getVoiceDutySweepPerSampleForTesting(int slot) const` + +**Description:** + +Test-only: continuous dutySweep delta per sample (0 when duty stepped). + +**Parameters:** + +- `slot`: Voice index [0, MAX_VOICES); out-of-range returns 0. diff --git a/api/generated/audio/AudioChannel.md b/api/generated/audio/AudioChannel.md index bf184fb..82eccc5 100644 --- a/api/generated/audio/AudioChannel.md +++ b/api/generated/audio/AudioChannel.md @@ -16,10 +16,14 @@ Designed to be static and memory-efficient. |------|------|-------------| | `sweepSamplesTotal` | `uint32_t` | Total samples for the sweep. | | `sweepSamplesRemaining` | `uint32_t` | Samples remaining in the sweep. | -| `sweepStartHz` | `float` | Starting frequency in Hz. | -| `sweepEndHz` | `float` | Ending frequency in Hz. | -| `sweepStartIncQ32` | `uint32_t` | Q32 phase increment at sweep start. | -| `sweepEndIncQ32` | `uint32_t` | Q32 phase increment at sweep end. | +| `sweepStartHz` | `float` | Starting frequency in Hz (NOISE: LFSR clock). | +| `sweepEndHz` | `float` | Ending frequency in Hz (NOISE: LFSR clock). | +| `sweepStartIncQ32` | `uint32_t` | Melodic: Q32 phase inc start; NOISE: start period. | +| `sweepEndIncQ32` | `uint32_t` | Melodic: Q32 phase inc end; NOISE: end period. | +| `sweepCurve` | `SweepCurve` | Active sweep curve (may fallback to Linear). | +| `sweepLogRatio` | `float` | FPU Exponential: logf(endHz/startHz). | +| `sweepLogStartQ16` | `int32_t` | Q15 path Exponential: log2(start) in Q16. | +| `sweepLogDeltaQ16` | `int32_t` | Q15 path Exponential: log2(end/start) in Q16. | ## Methods diff --git a/api/generated/audio/AudioCommandQueue.md b/api/generated/audio/AudioCommandQueue.md index 6d7e17a..4e4e5a6 100644 --- a/api/generated/audio/AudioCommandQueue.md +++ b/api/generated/audio/AudioCommandQueue.md @@ -6,18 +6,19 @@ ## Description -Multi-Producer Single-Consumer (MPSC) lock-free ring buffer for AudioCommands. +Single-Producer Single-Consumer (SPSC) lock-free ring buffer for AudioCommands. Fixed-size, zero-allocation queue designed for real-time audio thread communication. -Supports multiple concurrent producer threads (e.g., game logic, music sequencer) -and a single consumer thread (the audio thread). +Supports one producer (game/logic thread) and a single consumer (the audio thread). +Concurrent multi-producer use is not supported by this algorithm. Drop policy: When the queue is full, the newest command is silently dropped and the droppedCommands counter is incremented. Callers can monitor this via getDroppedCommands() for diagnostics. -Thread-safety: Uses compare-and-swap (CAS) for atomic ring index advancement. -The producer path is wait-free; the consumer path is lock-free. +Thread-safety: Atomic head/tail loads and stores for SPSC handoff. +Safe for one producer and one consumer; not wait-free under contention from +multiple producers. ## Methods @@ -25,7 +26,7 @@ The producer path is wait-free; the consumer path is lock-free. **Description:** -Enqueues a command. Thread-safe for multiple producers. +Enqueues a command. Safe for a single producer thread. **Parameters:** @@ -56,4 +57,4 @@ Checks if the queue is empty. **Description:** Returns the count of dropped commands due to queue full. -Thread-safe for concurrent reads from multiple producers. +Safe to read from the producer or consumer thread. diff --git a/api/generated/audio/AudioEngine.md b/api/generated/audio/AudioEngine.md index be792ac..2abb832 100644 --- a/api/generated/audio/AudioEngine.md +++ b/api/generated/audio/AudioEngine.md @@ -26,6 +26,16 @@ Usage: Initializes the engine and its internal scheduler. +### `void reinitSampleRate(int freq)` + +**Description:** + +Re-initializes the underlying ApuCore at a new sample rate. + +**Parameters:** + +- `freq`: The confirmed device sample rate, in Hz. + ### `void generateSamples(int16_t* stream, int length)` **Description:** diff --git a/api/generated/audio/SfxBreakpoint.md b/api/generated/audio/SfxBreakpoint.md new file mode 100644 index 0000000..a2089c0 --- /dev/null +++ b/api/generated/audio/SfxBreakpoint.md @@ -0,0 +1,19 @@ +# SfxBreakpoint + + + +**Source:** `AudioTypes.h` + +## Description + +Timed automation point for SFX duty steps or pitch envelope. + +`value` is duty in [0,1] for duty steps, or frequency/clock Hz for pitch. +Tables are static/constexpr in exported banks; AudioEvent holds pointer+count. + +## Properties + +| Name | Type | Description | +|------|------|-------------| +| `timeSec` | `float` | Offset from voice start (seconds); non-decreasing in a table. | +| `value` | `float` | Duty [0,1] or Hz > 0 depending on table context. | diff --git a/api/generated/index.md b/api/generated/index.md index 2c86347..a7a2e47 100644 --- a/api/generated/index.md +++ b/api/generated/index.md @@ -8,7 +8,7 @@ Auto-generated API documentation from C++ header files. - [AudioBackend](./audio/AudioBackend.md) — Abstract interface for platform-specific audio drivers. - [AudioChannel](./audio/AudioChannel.md) — Represents the internal state of a single audio channel. - [AudioCommand](./audio/AudioCommand.md) — Internal command to communicate between game and audio threads. -- [AudioCommandQueue](./audio/AudioCommandQueue.md) — Multi-Producer Single-Consumer (MPSC) lock-free ring buffer for AudioCommands. +- [AudioCommandQueue](./audio/AudioCommandQueue.md) — Single-Producer Single-Consumer (SPSC) lock-free ring buffer for AudioCommands. - [AudioConfig](./audio/AudioConfig.md) — Configuration for the Audio subsystem. - [AudioEngine](./audio/AudioEngine.md) — Facade class for the NES-style audio subsystem. - [AudioEvent](./audio/AudioEvent.md) — A fire-and-forget sound event triggered by the game. @@ -19,6 +19,7 @@ Auto-generated API documentation from C++ header files. - [LfoState](./audio/LfoState.md) — Holds LFO (Low-Frequency Oscillator) state for pitch or volume modulation. - [MusicNote](./audio/MusicNote.md) — Represents a single note in a melody. - [MusicPlayer](./audio/MusicPlayer.md) — Simple sequencer to play MusicTracks using the AudioEngine. +- [SfxBreakpoint](./audio/SfxBreakpoint.md) — Timed automation point for SFX duty steps or pitch envelope. ## Core diff --git a/api/generated/physics/CollisionSystem.md b/api/generated/physics/CollisionSystem.md index 4038c1b..d084067 100644 --- a/api/generated/physics/CollisionSystem.md +++ b/api/generated/physics/CollisionSystem.md @@ -122,7 +122,7 @@ Performs a swept collision test between a circle and an AABB. **Returns:** True if a collision occurs. -### `bool validateOneWayPlatform( pixelroot32::core::PhysicsActor* actor, pixelroot32::core::PhysicsActor* platform, const pixelroot32::math::Vector2& collisionNormal )` +### `bool validateOneWayPlatform( pixelroot32::core::PhysicsActor* actor, pixelroot32::core::PhysicsActor* platform, const pixelroot32::math::Vector2& collisionNormal, pixelroot32::math::Scalar motionStartHitboxBottom = pixelroot32::math::toScalar(-1.0f)` **Description:** @@ -133,5 +133,7 @@ Validates if a collision with a one-way platform should occur. - `actor`: The moving actor. - `platform`: The one-way platform. - `collisionNormal`: The contact normal. +- `motionStartHitboxBottom`: Hitbox bottom at the start of the current move probe + (used by kinematic moveAndCollide). Negative to use previousPosition instead. **Returns:** True if the collision is valid and should be resolved. diff --git a/api/generated/physics/KinematicActor.md b/api/generated/physics/KinematicActor.md index 4b70b78..73eb604 100644 --- a/api/generated/physics/KinematicActor.md +++ b/api/generated/physics/KinematicActor.md @@ -74,6 +74,22 @@ Clears floor velocity and state. Returns true if the body collided with a wall. +### `void setStrictTopSurfaceFloor(bool strict)` + +**Description:** + +Enables or disables top-surface validation for floor state and kinematic carry. + +**Parameters:** + +- `strict`: When true (default), floor contact requires horizontal overlap on the top face. + +### `inline bool isStrictTopSurfaceFloor() const` + +**Description:** + +Returns whether top-surface floor validation is active. + ### `void draw(pixelroot32::graphics::Renderer& renderer)` **Description:** diff --git a/architecture/audio-subsystem.md b/architecture/audio-subsystem.md index 93ac851..f533639 100644 --- a/architecture/audio-subsystem.md +++ b/architecture/audio-subsystem.md @@ -23,9 +23,9 @@ high-level architecture and the concrete implementation details. - **Dynamic voice pool** inside **`ApuCore`** (default **`MAX_VOICES = 8`**). Each voice holds the same oscillator/envelope state as before (`AudioChannel`, exposed as alias **`Voice`** in [`AudioTypes.h`](include/audio/AudioTypes.h)). - **Public API unchanged**: games and music still describe sounds with **`WaveType`** on **`AudioEvent`** / **`MusicTrack::channelType`**. Internally, `ApuCore` maps `WaveType` ↔ **`VoiceType`** (`PULSE`, `TRIANGLE`, `NOISE`, `SINE`, `SAW`) for allocation policy; the active waveform on a voice is still a `WaveType` on that voice’s state. -- **Voice allocation**: on `PLAY_EVENT`, `ApuCore` prefers an **inactive** voice whose stored type matches the requested `WaveType`; if none, it may **steal** the active voice with the **smallest `remainingSamples`**. When all eight voices are busy, the newest event replaces the “shortest remaining” note (same steal metric as under the old 2-voice melodic pool, but generalized across the whole pool). +- **Voice allocation**: melodic sequencer tracks use **fixed slots 0–3** (`trackIdx` → slot). **Sequencer percussion hits** (NOISE + kit preset with `duty == 0`) and **`PLAY_EVENT` SFX** share the **SFX subpool** (slots **4–7**). Allocation searches only 4–7, preferring an inactive voice whose stored type matches the requested `WaveType`; if none, it may **steal** the active SFX voice with the **smallest `remainingSamples`**. SFX never steals melodic slots 0–3, and concurrent music tracks with the same `WaveType` do not interrupt each other. - Software mixing into a **mono** 16-bit (`int16_t`) stream. -- **Event-driven** model: games fire short-lived `AudioEvent` instances (SFX, notes). +- **Event-driven** model: games fire `AudioEvent` instances (SFX one-shots, loops, or scheduled sequences; music notes). - **Conditionally compiled**: Entire subsystem can be excluded with `PIXELROOT32_ENABLE_AUDIO=0` to save firmware size and RAM. - **Platform-agnostic API** with concrete work split as follows: - [`AudioEngine`](include/audio/AudioEngine.h) is a thin facade: it forwards `playEvent` / `setMasterVolume` as commands and delegates `generateSamples` to an `AudioScheduler`. @@ -103,7 +103,7 @@ Highlights (see [`AudioTypes.h`](include/audio/AudioTypes.h) for the full struct - **Float path** (FPU ESP32, native): `phase` in `[0,1)`, `phaseIncrement = frequency / sampleRate`, linear volume ramp (`volume`, `targetVolume`, `volumeDelta`). - **Integer mirror** (ESP32-C3, no FPU): `phaseQ32`, `phaseIncQ32`, `dutyCycleQ32` — updated on each `PLAY_EVENT` so the hot inner loop in `ApuCore::generateSamples` avoids per-sample soft-float. - **Noise**: `lfsrState` (15-bit LFSR), `noisePeriodSamples`, `noiseCountdown` — same deterministic polynomial on **all** platforms. -- **Lifetime**: `remainingSamples` counts down each rendered sample until the voice is disabled (then it can be reused or stolen per §3.5). +- **Lifetime**: `remainingSamples` counts down each rendered sample until the voice is disabled, unless **`loop == true`** (see §2.3). Looping voices use a sentinel (`UINT64_MAX`) and do not auto-disable; they may be stolen per §3.5. On **note on**, `ApuCore` initializes an ADSR envelope from the `InstrumentPreset` (or legacy defaults: 2ms attack, no decay, full sustain, 5ms release) to shape the note amplitude over time, reducing clicks and enabling expressive articulation. @@ -115,17 +115,22 @@ Also defined in `AudioTypes.h`: struct AudioEvent { WaveType type; float frequency; - float duration; // seconds + float duration; // seconds (one-shot length; ignored as finite gate when loop==true) float volume; // 0.0 - 1.0 float duty; // only for PULSE uint8_t noisePeriod = 0; // for NOISE: 0=calc from frequency, >0=direct LFSR period const InstrumentPreset* preset = nullptr; - float sweepEndHz = 0.0f; // linear sweep target (PULSE/TRIANGLE); 0 = off + float sweepEndHz = 0.0f; // linear sweep target (melodic Hz or NOISE clock Hz) float sweepDurationSec = 0.0f; // sweep length; 0 = off + bool loop = false; // continuous until STOP_CHANNEL or steal (see below) }; ``` -- **Linear sweep** (optional): when `sweepDurationSec > 0` and `sweepEndHz > 0`, and `type` is `PULSE` or `TRIANGLE`, frequency moves linearly from `frequency` toward `sweepEndHz` over `min(ceil(sweepDurationSec * sampleRate), note samples)`; `NOISE` ignores these fields. +- **Linear sweep** (optional): when `sweepDurationSec > 0` and `sweepEndHz > 0`: + - **PULSE / TRIANGLE / SINE / SAW**: frequency moves linearly from `frequency` toward `sweepEndHz` over `min(ceil(sweepDurationSec * sampleRate), note samples)` for one-shots, or the full sweep duration when `loop == true`. + - **NOISE**: interpolates the **LFSR clock** (period in samples) from the initial value (`noisePeriod` or derived from `frequency`) toward the period implied by `sweepEndHz`. +- **`loop`**: when `true`, the voice stays enabled until **`STOP_CHANNEL`** on that voice slot or **voice steal** under SFX pool saturation. `remainingSamples` uses `UINT64_MAX` and does not count down. +- **One-shot safety**: when `loop == false` and `duration <= 0`, `ApuCore` **disables the voice immediately** (no hanging voice). - It is the basic unit used to trigger a sound. - It is passed as a parameter to `AudioEngine::playEvent`. - **Note**: Only available when `PIXELROOT32_ENABLE_AUDIO=1` @@ -150,8 +155,9 @@ The audio system no longer uses `deltaTime` or frame-based updates. Instead, it - **Audio Time**: Internal unit is samples (e.g., 1 second = 44100 samples at 44.1kHz). - **Decoupled Logic**: The `AudioScheduler` runs in a separate thread (SDL2) or core (ESP32). -- **Lifetime**: For each active **voice** (`AudioChannel` / `Voice`), `ApuCore` subtracts 1 from `remainingSamples` for every sample generated. +- **Lifetime**: For each active **voice** (`AudioChannel` / `Voice`), `ApuCore` subtracts 1 from `remainingSamples` for every sample generated **unless `loop == true`**. - When `remainingSamples` reaches 0, the voice is automatically disabled (unless extended by the ADSR **RELEASE** phase per existing rules). +- **Looping SFX**: `loop == true` sets `remainingSamples = UINT64_MAX`; stop explicitly with **`STOP_CHANNEL`** or rely on steal when the SFX subpool is full. Important: @@ -165,7 +171,7 @@ Oscillator work is implemented in **`ApuCore::generateSampleForVoice`** (float p 1. **`PULSE`**: square with configurable duty (`dutyCycle` / `dutyCycleQ32`). 2. **`TRIANGLE`**: symmetric triangle in `[-1, 1]` (float) or quantized from `phaseQ32` high bits (integer path). -3. **`NOISE`**: **NES-style 15-bit LFSR** on **all** targets — same bit taps, advanced when `noiseCountdown` reaches 0, then reloaded from `noisePeriodSamples`. `AudioEvent::frequency` for noise sets the default clock when `noisePeriod == 0`; otherwise a fixed period can be supplied for percussion. +3. **`NOISE`**: **NES-style 15-bit LFSR** on **all** targets — same bit taps, advanced when `noiseCountdown` reaches 0, then reloaded from `noisePeriodSamples`. `AudioEvent::frequency` for noise sets the default clock when `noisePeriod == 0`; otherwise a fixed period can be supplied for percussion. Optional **period sweep** uses `sweepEndHz` / `sweepDurationSec` (clock Hz target, not melodic pitch). After the per-voice sample (float path): @@ -221,9 +227,28 @@ When **`PIXELROOT32_ENABLE_PROFILING`** is defined (`platforms/EngineConfig.h` `ApuCore` then: -- Selects a **voice slot** via **`findVoiceForEvent(WaveType)`**: prefers an **inactive** voice whose `type` already matches the requested `WaveType`; otherwise picks any inactive slot; if **all** voices are active, **steals** the voice with the **minimum `remainingSamples`** (breaking ties by scan order). -- Converts the event's duration (seconds) into `remainingSamples` based on the current sample rate. -- Initializes the voice state (`enabled`, `frequency`, `phase`, fixed-point mirrors, ADSR envelope, LFSR for noise, etc.) and sets `type` from the event. +- Selects a **voice slot** via **`findVoiceForSfxEvent(WaveType)`** (slots **4–7** only): prefers an **inactive** voice whose `type` already matches the requested `WaveType`; otherwise picks any inactive slot in the subpool; if **all four** SFX slots are active, **steals** the voice with the **minimum steal score** (breaking ties by scan order). **Looping voices score 0** (treated as lowest remaining priority) so new one-shots can replace a stuck engine/motor loop under saturation. +- Converts the event's duration (seconds) into `remainingSamples` based on the current sample rate, or sets the loop sentinel when `event.loop == true`. +- Initializes the voice state (`enabled`, `frequency`, `phase`, fixed-point mirrors, ADSR envelope, LFSR for noise, sweep state, etc.) and sets `type` from the event. + +### 3.6 Sequencer percussion and SFX coexistence + +Background music **melody / bass / harmony** always occupy fixed slots **0–2** (and slot **3** only for **melodic** notes on the fourth track). **Drum-kit hits** from the music sequencer are **not** monophonic on slot 3: each hit is a one-shot routed through **`playSequencerPercussionHit` → `findVoiceForSfxEvent(NOISE)`**, the same subpool as **`PLAY_EVENT`** (jump, damage, power-up, etc.). + +| Source | Voice slots | Notes | +|--------|-------------|--------| +| Melodic tracks 0–3 | 0–3 (fixed per `trackIdx`) | Gates in beats; `Rest` note-off scoped per track | +| Sequencer percussion (kit preset, `duty == 0`) | 4–7 (shared) | Same-step kick+snare+hi-hat can use up to three concurrent NOISE voices | +| Gameplay `PLAY_EVENT` SFX | 4–7 (shared) | Steal policy when subpool saturated | + +**Practical limits (documented for future tuning):** + +- At most **four** concurrent voices among **all** sequencer drums **plus** gameplay SFX. +- Under saturation, the voice with the **lowest steal score** in 4–7 may be stolen — looping SFX are preferred steal targets; no separate drum-vs-SFX priority today (acceptable for typical retro titles with short one-shots). +- Melodic music in 0–3 is **never** affected by drum or SFX allocation. +- Stacked same-step drums use `MusicNote.duration == 0` on all but the last hit so the sequencer fires every hit on one tick without advancing tempo between them. + +**Future options** (not implemented): reserved SFX slots for gameplay, drum-vs-SFX steal priority, or a larger voice pool. --- @@ -471,14 +496,29 @@ Internally this uses `AudioCommandType::SET_MASTER_BITCRUSH` on the same SPSC qu Effects are built by combining basic parameters and optional ADSR envelopes: **Basic Parameters** -- `frequency`: lower or higher pitch (sweep start when using `sweepEndHz` / `sweepDurationSec`). -- `duration`: effect length (seconds). +- `frequency`: lower or higher pitch (sweep start when using `sweepEndHz` / `sweepDurationSec`). On **NOISE**, sets the initial LFSR clock when `noisePeriod == 0`. +- `duration`: effect length (seconds) for one-shots; must be **> 0** when `loop == false`. - `volume`: 0.0–1.0. +- `loop`: continuous tone (motor, ambience) until **`STOP_CHANNEL`** on the voice slot or steal. - `duty` (pulse only): - 0.125: thinner, sharper timbre. - 0.25: classic "NES lead". - 0.5: symmetric square, "fatter" sound. +**Stopping a looping SFX** + +```cpp +// After playEvent on a looping event, stop the voice slot when the effect should end: +pr32::audio::AudioCommand stop{}; +stop.type = pr32::audio::AudioCommandType::STOP_CHANNEL; +stop.channelIndex = voiceSlot; // 0..MAX_VOICES-1 +engine.getAudioEngine().submitCommand(stop); +``` + +**Timed SFX sequences (arpeggios)** + +The Engine does **not** embed an SFX step sequencer inside `ApuCore`. Prefer the opt-in helper [`playSfxBank`](include/audio/SfxBankPlayback.h): it dispatches Tool Suite bank `layerEvent` calls at `t = 0` and hands delayed `sequenceStep` entries to a game-supplied `SfxDelayScheduler` (scene timer / command queue). Looping voices still require `STOP_CHANNEL` (or steal); the helper does not manage cooldowns or global SFX volume. Legacy games may keep iterating `layerCount` / `layerEvent` only. See Tool Suite `docs/SFX_ENGINE_ABI_REFERENCE.md`. + **ADSR Envelope (via `InstrumentPreset`)** - `attackTime`: how quickly the sound reaches peak volume (0.0 = instant). - `decayTime`: how quickly it drops to sustain level after attack. @@ -666,7 +706,7 @@ void GeometryJumpScene::init() { } ``` -- Music uses the same **`executePlayEvent`** path as SFX, so melody notes **consume slots in the voice pool** alongside sound effects. With **8** voices, simple lead lines still leave headroom for SFX; dense multi-track + rapid SFX can trigger **voice stealing** (§3.5). +- Melodic sequencer notes use **fixed slots 0–3**; **sequencer percussion** and **`PLAY_EVENT` SFX** share slots **4–7** (see §3.6). With **8** voices, simple lead lines still leave headroom for drums and SFX; dense drum patterns plus rapid SFX can trigger **voice stealing** within the SFX subpool only (§3.5–3.6). - Timing is **sample-accurate** in `ApuCore`, so melody playback does not depend on render FPS; game logic should still avoid assuming instant delivery of enqueued commands if the audio queue overflows (§3.5). ### 7.5 Multi-track music playback @@ -720,7 +760,9 @@ With the **Multi-Core Architecture (v0.7.0-dev)**, many previous limitations wer - **ADSR Envelopes**: Full Attack-Decay-Sustain-Release envelopes implemented via `InstrumentPreset` for expressive note articulation and click-free playback. - **LFO Modulation**: Low-frequency oscillators for vibrato (pitch) and tremolo (volume) effects. - **Multi-track Music**: Support for up to 4 simultaneous tracks (main + 3 sub-tracks) with independent voices and percussion. -- **Linear frequency sweep**: optional portamento on `PULSE` / `TRIANGLE` via `AudioEvent::sweepEndHz` and `sweepDurationSec` (sample-accurate linear interpolation; applied before LFO pitch modulation each sample). +- **Linear frequency sweep**: optional portamento on **PULSE / TRIANGLE / SINE / SAW** via `AudioEvent::sweepEndHz` and `sweepDurationSec` (sample-accurate linear interpolation; applied before LFO pitch modulation each sample). +- **Noise period sweep**: same sweep fields on **`NOISE`** interpolate LFSR clock Hz (period), enabling descending “scream” / boom effects. +- **Continuous SFX (`loop`)**: `AudioEvent::loop == true` holds a voice until **`STOP_CHANNEL`** or steal; one-shots with `duration <= 0` and `loop == false` disable immediately (no accidental hang). - **Master bitcrush**: optional `AudioEngine::setMasterBitcrush` (0–15) on the final `int16_t` bus. - **extra waveforms**: `WaveType::SINE` (256-point LUT in [`AudioOscLUT.h`](include/audio/AudioOscLUT.h)) and `WaveType::SAW` (linear ramp); both are first-class `WaveType` values allocated from the **same `MAX_VOICES` pool** as pulse/triangle/noise. `executePlayEvent` sets each voice’s `type` per note; linear sweep applies to SINE/SAW as well. - **— post-mix hook**: optional `AudioConfig::postMixMono` / `postMixUser`, applied in `ApuCore::generateSamples` after bitcrush on the full buffer. **RT-safe contract:** no heap allocation, no mutexes, bounded work. `AudioEngine::init` forwards the pointer to `ApuCore::setPostMixMono`. diff --git a/examples/music_demo.md b/examples/music_demo.md index b075b95..3863474 100644 --- a/examples/music_demo.md +++ b/examples/music_demo.md @@ -32,13 +32,13 @@ Tracks are split per theme; shared beat constants and demo-only **`InstrumentPre | File | Role | |------|------| -| [`common_melodies.h`](src/assets/common_melodies.h) | Beat fractions (`S`/`E`/`Q`/…), `kDemoArcadeLeadWave` / `kDemoAdventureLeadWave`, **`DEMO_SNES_LEAD_TIGHT`** / **`DEMO_SNES_BASS_STAC`** (tighter ADSR for SNES-style arranging), **`ARP_STEP`** | +| [`common_melodies.h`](src/assets/common_melodies.h) | Beat fractions (`S`/`E`/`Q`/…), wave aliases, demo **`InstrumentPreset`** overrides (`DEMO_MELODY_LEAD`, `DEMO_HARMONY`, `DEMO_DRUM_*`, `DEMO_SNES_*`, `DEMO_ARP_VOICE`) retuned for beat-accurate gates under ApuCore 4+4 | | [`classic_arcade_melody.h`](src/assets/classic_arcade_melody.h) | **Melody 1** — Classic Arcade (`sClassicArcadeTrack`) | | [`adventure_melody.h`](src/assets/adventure_melody.h) | **Melody 2** — Adventure (`sAdventureTrack`) | | [`action_melody.h`](src/assets/action_melody.h) | **Melody 3** — Action (`sActionTrack`) | | [`arpeggio_melody.h`](src/assets/arpeggio_melody.h) | **Melody 4** — Em arpeggio demo (`sArpDemoTrack`) | -Each full arrangement uses **`MusicTrack`** layering: **main** + optional **`secondVoice`**, **`thirdVoice`**, and **`percussion`**, flattened by `MusicPlayer` into the global voice pool (**`ApuCore::MAX_VOICES`** = 8). The headers comment on keeping harmony/percussion notes relatively short so **SFX** can share the pool without constant stealing. +Each full arrangement uses **`MusicTrack`** layering: **main** + optional **`secondVoice`**, **`thirdVoice`**, and **`percussion`**, mapped by `MusicPlayer` to ApuCore music slots **0–3** (SFX keeps slots **4–7**). Demo presets in **`common_melodies.h`** add level and tail so loops stay full after beat-accurate gates. ## Melodies (UI labels vs. engine) diff --git a/guide/audio.md b/guide/audio.md index 3ad934c..b3ebd01 100644 --- a/guide/audio.md +++ b/guide/audio.md @@ -80,6 +80,21 @@ void playCoin(pr32::core::Engine& engine) { - **`frequency`** on a **NOISE** event does **not** set musical pitch in **`ApuCore`**. It drives the **noise clock**: default period in samples is `sample_rate / max(frequency, 1 Hz)` when `noisePeriod == 0`. Lower values → coarser / more “hit-like”; higher values → denser noise. Use **`noisePeriod`** for fixed percussion periods. - **All platforms** share the same **15-bit NES-style LFSR** inside **`ApuCore`**; step rate follows `noisePeriodSamples` / `noiseCountdown` (and `AudioEvent`), not `rand()`. +- **Period sweep**: with `sweepDurationSec > 0` and `sweepEndHz > 0`, **`NOISE`** interpolates the LFSR clock from the initial period toward the period implied by `sweepEndHz` (descending “scream” / boom effects). + +### Linear sweep and loop (`AudioEvent`) + +| Field | Melodic waves | NOISE | +|-------|---------------|-------| +| `sweepEndHz` | Target frequency (Hz) | Target LFSR clock (Hz) | +| `sweepDurationSec` | Sweep length (seconds) | Same | +| `loop` | Hold voice until `STOP_CHANNEL` or steal | Same | + +- One-shots: `duration > 0` and `loop == false` (default). +- Continuous: `loop == true`; duration is not used as a finite gate. +- **`duration <= 0` with `loop == false`** disables the voice immediately (safe no-op). + +Arpeggios and fanfares are **not** built into the Engine: schedule multiple `playEvent` calls at time offsets (Tool Suite exported banks provide `sequenceStepCount` / `sequenceStep` helpers — see Tool Suite `SFX_ENGINE_ABI_REFERENCE.md`). ### Master volume diff --git a/guide/music-player-guide.md b/guide/music-player-guide.md index df733a0..4f7e11b 100644 --- a/guide/music-player-guide.md +++ b/guide/music-player-guide.md @@ -6,7 +6,7 @@ The `MusicPlayer` class provides a simple yet powerful way to add background mus **Modular Compilation:** The MusicPlayer is only compiled when `PIXELROOT32_ENABLE_AUDIO=1`. When disabled, all music-related functionality is excluded from the build, saving both firmware size and RAM usage. `MusicTrack::channelType` supports `PULSE`, `TRIANGLE`, `NOISE`, `SINE`, and `SAW`. -**Voice pool vs. sequencer tracks:** `MusicPlayer` can arrange up to **`MAX_MUSIC_TRACKS` (4)** logical layers (main + sub-tracks), but every note still becomes a `PLAY_EVENT` inside **`ApuCore`**, which mixes at most **`ApuCore::MAX_VOICES` (8)** simultaneous **voices**. Dense chords, fast arps, **plus** heavy SFX can exceed eight concurrent notes and trigger **voice stealing** (shortest remaining note is replaced). Author shorter note lengths or fewer simultaneous layers if you need deterministic timbres on hardware. +**Voice pool vs. sequencer tracks:** `MusicPlayer` can arrange up to **`MAX_MUSIC_TRACKS` (4)** logical layers (main + sub-tracks), but every note still becomes a `PLAY_EVENT` inside **`ApuCore`**, which mixes at most **`ApuCore::MAX_VOICES` (8)** simultaneous **voices**. Dense chords, fast arps, **plus** heavy SFX can exceed eight concurrent notes and trigger **voice stealing** (lowest steal score; looping voices are preferred steal targets). Author shorter note lengths or fewer simultaneous layers if you need deterministic timbres on hardware. This guide covers everything from basic music playback to advanced patterns like adaptive soundtracks and smooth transitions. @@ -562,7 +562,7 @@ For global lo-fi degradation or analysis, use **`AudioEngine::setMasterBitcrush` ### Related API (sweeps and extra waves) -One-shot **frequency sweeps** on `AudioEvent` (`sweepEndHz`, `sweepDurationSec`) apply to **`PULSE`** and **`TRIANGLE`** (and to **`SINE`** / **`SAW`** when extra waves are enabled). **`NOISE`** ignores sweep fields. Full detail: [audio.md](../api/audio.md). +One-shot **frequency / period sweeps** on `AudioEvent` (`sweepEndHz`, `sweepDurationSec`) apply to **`PULSE`**, **`TRIANGLE`**, **`SINE`**, and **`SAW`** (melodic Hz), and to **`NOISE`** (LFSR clock / period interpolation). Set **`loop = true`** for continuous SFX until **`STOP_CHANNEL`**. Full detail: [audio.md](../api/audio.md) and [Audio subsystem](../architecture/audio-subsystem.md). ---