diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index ee4cea9..9665d47 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -154,6 +154,39 @@ pitch envelope/curve (AD?) which is off by default."* - **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2 feature list (velocity layers / round-robin / filter work) drive their build shape. +### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30) + +Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We +must live compute, latching the parameters at note on is not acceptable. long term these will be +automatable parameters."* It rejects the precedent, not one instance of it. + +- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and + `liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and + why each exclusion is excluded — see there rather than restating the list here. +- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one + block the shell owns per instance. The member-ordering constraint that enforces it, and why, + are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice + tracking the knob is the DESIRED behaviour — it is the note the user is hearing. +- **Null is the bare engine.** `live == nullptr` is byte-identical to the pre-live core, which + is why `sampler_core`'s regression baselines needed no change. +- **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once + per `render()` and once per note-on; the per-sample path gained three predicted branches (the + voice's filter-ramp check and each envelope smoother's active check), all false at rest, and + no indirection. +- **A fresh note SNAPS, a sounding one holds φ.** They are different entry points on purpose + (`snapLive` vs `applyLive`): a voice that has rendered nothing has no phase to hold, and the + φ rule reads its stage-0 position under a stale zero-length stage as a completed stage. One + function serving both silently discarded every newly-dialled attack. +- **The mid-stage rule is HOLD NORMALIZED STAGE POSITION** (Daniel's pick among six candidates): + φ = elapsed/duration is held across a stage-time change, so the level is continuous by + construction and the remainder takes its share of the new duration. Stated over normalized + position rather than output level ON PURPOSE, so a per-segment curve exponent composes with + it as a pure map of φ. Recomputing from absolute elapsed (which steps) is the rejected + alternative — do not reintroduce it. +- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the + voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by + the envelope's own bounded offset smoother. + ### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md) - **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not @@ -199,7 +232,8 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma - The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render: - `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. - - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. + - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover. + - `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns. - `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. @@ -227,7 +261,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma - `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `embed_strip` — compact single-row control layout for embed mode in the track FX chain. - `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types. -- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. +- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. - `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types. - `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary. - `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge. @@ -243,6 +277,17 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth for both — do not reintroduce either superseded design. +- **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact + skip at `modAmount == 0` skips the envelope tick along with the solve, so dialling depth up + mid-note starts the envelope from the note's stage-0 position rather than from where it would + have been. Its step smoother is frozen with it — an absorbed step sits in the offset and + emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0). + Continuous either way (the contribution starts at 0), and keeping the skip is what holds the + at-rest per-sample path byte-identical — but don't read a live depth move as "resuming" an + envelope that was never running. +- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the + snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by + `snapLive` before its first frame. - **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep covers client-pixel widths (including multiples standing in for larger client areas); nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 7f1d2e4..33c13fe 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -16,12 +16,19 @@ reasampler_test(master_gain LINK master_gain) # Declared before sampler_core because the voice now runs one per sounding note. add_subdirectory(filter) +# The live-parameter block: the value layer plus its publication, deliberately linking no +# engine — the block is a plain value the voice observes, not a thing the engine owns. +reasampler_pure_library(live_params + SOURCES live_params.cpp + LINK PUBLIC peaks velocity_curve filter) +reasampler_test(live_params LINK live_params) + # Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and # block render). The per-sample render half stays inline in voice.h precisely so this TU # boundary costs the hot path nothing. reasampler_pure_library(sampler_core SOURCES voice.cpp voice_engine.cpp - LINK PUBLIC peaks pitch_shift velocity_curve filter) + LINK PUBLIC peaks pitch_shift velocity_curve filter live_params) # Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3 # or REAPER type reaching the core would fail to compile or link here. reasampler_test(sampler_core LINK sampler_core) @@ -29,3 +36,7 @@ reasampler_test(sampler_core LINK sampler_core) # The filter's own seams are covered by the four targets in filter/; this one covers the # integration: pipeline order, per-voice independence, and the off-by-default bit-identity. reasampler_test(sampler_filter LINK sampler_core) + +# Live delivery is the third integration seam over the same engine: what a published block +# does to a voice that is already sounding, and what it must leave alone. +reasampler_test(live_delivery LINK sampler_core) diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h index 219ea37..8608cf8 100644 --- a/src/core/instrument/engine/envelopes.h +++ b/src/core/instrument/engine/envelopes.h @@ -12,6 +12,41 @@ namespace reasampler { +// Absorbs a step a live parameter move would otherwise put straight into an evaluator's +// output, as an offset that decays to EXACTLY zero — so the at-rest path carries no residue +// and the smoother's own branch stays predictably false. Per-frame decay rather than a +// wall-clock one, matching the voice's takeover declick; the floor is far below both domains +// this is used in (amplitude, and semitones of pitch offset). +class StepSmoother { +public: + // `step` is (level before the change - level after it): adding it back reproduces the + // pre-change output exactly on the first frame. + void absorb(double step) { + offset_ += step; + active_ = (offset_ > kFloor || offset_ < -kFloor); + if (!active_) offset_ = 0.0; + } + void clear() { offset_ = 0.0; active_ = false; } + bool active() const { return active_; } + + // This frame's offset; decays afterwards, latching inactive at the floor. + double advance() { + const double out = offset_; + offset_ *= kDecay; + if (offset_ < kFloor && offset_ > -kFloor) { + offset_ = 0.0; + active_ = false; + } + return out; + } + +private: + static constexpr double kDecay = 0.95; + static constexpr double kFloor = 1e-5; + double offset_ = 0.0; + bool active_ = false; +}; + // AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate: // noteOn() enters Attack; noteOff() enters Release from wherever it is. // @@ -24,6 +59,11 @@ namespace reasampler { // A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold // entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff // during attack/hold/decay releases from the current partial level, not from sustainLevel. +// +// stagePos_ is the elapsed position within the current stage. It is a double rather than a +// frame count only so applyLive can hold a fractional normalized position; every value it +// takes on the un-edited path is integral, so the segment math is bit-identical to the +// integer-counter engine. class AdsrEnvelope { public: enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished }; @@ -34,25 +74,122 @@ public: void noteOn() { stage_ = Stage::Attack; level_ = 0.0; - framesInStage_ = 0; + stagePos_ = 0.0; + smooth_.clear(); } // Gate off: enter Release from the CURRENT level — release-before-sustain releases from - // the partial attack/decay level, not from sustainLevel. + // the partial attack/decay level, not from sustainLevel. A running smoother deliberately + // survives: it is mid-glide, and cutting it here would reintroduce the step it absorbed. void noteOff() { if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) { return; // already released / not sounding. } releaseFrom_ = level_; stage_ = Stage::Release; - framesInStage_ = 0; + stagePos_ = 0.0; + } + + // Live parameter delivery to a fresh voice — one that has NOT yet rendered a frame, whose + // latched copy may predate the newest edit. It takes the params outright: there is no + // phase to hold and nothing to be continuous with. applyLive cannot serve here in either + // direction — with a stale duration of 0 its phi rule reads stagePos_ == 0 as a COMPLETED + // stage and discards the newly-dialled time, and with a stale duration > 0 against a new 0 + // it absorbs a full-scale step into a voice that has emitted nothing, fading the onset in. + void snapLive(const AdsrParams& params) { + params_ = params; + smooth_.clear(); + } + + // Live parameter delivery to a SOUNDING voice. The mid-stage rule is HOLD NORMALIZED + // STAGE POSITION: phi = elapsed/duration is kept fixed across the change, so this frame's + // level is unchanged by construction and the remainder of the stage takes its share of the + // newly-dialled duration. The rule is expressed over normalized position, never over + // output level, so a per-segment curve exponent composes with it as a pure map of phi. + // + // Two cases phi cannot cover, both absorbed by the smoother rather than allowed to step: + // a sustain level moved while the voice holds it (sustain is a level, not a timed stage), + // and a stage duration dialled to exactly zero mid-stage (the stage ceases to exist and + // completes at its terminal level). + void applyLive(const AdsrParams& params) { + const double before = stageLevel(params_); + const double oldDuration = stageDuration(params_); + const double newDuration = stageDuration(params); + if (newDuration > 0.0) { + stagePos_ = (oldDuration > 0.0) ? stagePos_ * (newDuration / oldDuration) + : newDuration; // a collapsed stage was complete + } + params_ = params; + const double after = stageLevel(params_); + if (after != before) smooth_.absorb(before - after); } // Advances one frame and returns the amplitude for THIS frame (before advancing). // Once Release completes the envelope latches Finished and returns 0.0 forever (until // the next noteOn). A single, monotonic per-frame step — the caller pulls one value per // output frame. + // + // While the smoother runs the return may sit OUTSIDE [0,1] by the offset it is decaying + // (bounded by the step it absorbed). finished() ignores that residue, so a Release that + // completes with an offset still decaying is hard-cut when the voice frees — the audible + // remainder of a step the smoother had already taken most of. double tick() { + const double out = tickStage(); + return smooth_.active() ? out + smooth_.advance() : out; + } + + Stage stage() const { return stage_; } + bool finished() const { return stage_ == Stage::Finished; } + double level() const { return level_; } + +private: + // The level tick() would emit right now under `params` without advancing anything. THE one + // home for every segment's shape: tickStage owns only the advance and the stage + // transitions and reads its output from here, so a per-segment curve added later lands in + // one place and the smoother can never size a step against a different curve than the + // output takes. + double stageLevel(const AdsrParams& params) const { + switch (stage_) { + case Stage::Attack: { + if (params.attackFrames <= 0) return 1.0; + const double l = stagePos_ / static_cast(params.attackFrames); + return l > 1.0 ? 1.0 : l; + } + case Stage::Hold: + // A zero-length hold falls straight through to Decay on the next tick, whose + // level at position 0 is 1.0 — unless decay is zero too, which lands on sustain. + if (params.holdFrames > 0) return 1.0; + return (params.decayFrames <= 0) ? params.sustainLevel : 1.0; + case Stage::Decay: { + if (params.decayFrames <= 0) return params.sustainLevel; + const double t = stagePos_ / static_cast(params.decayFrames); + return 1.0 + (params.sustainLevel - 1.0) * t; + } + case Stage::Sustain: + return params.sustainLevel; + case Stage::Release: { + if (params.releaseFrames <= 0) return 0.0; + const double t = stagePos_ / static_cast(params.releaseFrames); + const double l = releaseFrom_ * (1.0 - t); + return l < 0.0 ? 0.0 : l; + } + default: + return 0.0; + } + } + + // The current stage's dialled duration under `params`; 0 for the untimed stages. + double stageDuration(const AdsrParams& params) const { + switch (stage_) { + case Stage::Attack: return static_cast(params.attackFrames); + case Stage::Hold: return static_cast(params.holdFrames); + case Stage::Decay: return static_cast(params.decayFrames); + case Stage::Release: return static_cast(params.releaseFrames); + default: return 0.0; + } + } + + double tickStage() { switch (stage_) { case Stage::Idle: case Stage::Finished: @@ -60,19 +197,13 @@ public: return 0.0; case Stage::Attack: { - if (params_.attackFrames <= 0) { - level_ = 1.0; - } else { - level_ = static_cast(framesInStage_) / - static_cast(params_.attackFrames); - if (level_ > 1.0) level_ = 1.0; - } + level_ = stageLevel(params_); const double out = level_; - ++framesInStage_; - if (framesInStage_ >= params_.attackFrames) { + stagePos_ += 1.0; + if (stagePos_ >= static_cast(params_.attackFrames)) { // holdFrames == 0 falls straight through Hold on the next tick to Decay. stage_ = Stage::Hold; - framesInStage_ = 0; + stagePos_ = 0.0; level_ = 1.0; } return out; @@ -84,43 +215,38 @@ public: // extra sample. if (params_.holdFrames <= 0) { stage_ = Stage::Decay; - framesInStage_ = 0; + stagePos_ = 0.0; level_ = 1.0; // Single re-dispatch into Decay (bounded: Hold->Decay only, not general - // recursion). - return tick(); + // recursion). Re-enters the STAGE evaluator, never tick(), so a running + // smoother is applied exactly once per frame. + return tickStage(); } - level_ = 1.0; + level_ = stageLevel(params_); const double out = level_; - ++framesInStage_; - if (framesInStage_ >= params_.holdFrames) { + stagePos_ += 1.0; + if (stagePos_ >= static_cast(params_.holdFrames)) { stage_ = Stage::Decay; - framesInStage_ = 0; + stagePos_ = 0.0; level_ = 1.0; } return out; } case Stage::Decay: { - if (params_.decayFrames <= 0) { - level_ = params_.sustainLevel; - } else { - const double t = static_cast(framesInStage_) / - static_cast(params_.decayFrames); - level_ = 1.0 + (params_.sustainLevel - 1.0) * t; - } + level_ = stageLevel(params_); const double out = level_; - ++framesInStage_; - if (framesInStage_ >= params_.decayFrames) { + stagePos_ += 1.0; + if (stagePos_ >= static_cast(params_.decayFrames)) { stage_ = Stage::Sustain; - framesInStage_ = 0; + stagePos_ = 0.0; level_ = params_.sustainLevel; } return out; } case Stage::Sustain: - level_ = params_.sustainLevel; + level_ = stageLevel(params_); return level_; case Stage::Release: { @@ -129,13 +255,10 @@ public: stage_ = Stage::Finished; return 0.0; } - const double t = static_cast(framesInStage_) / - static_cast(params_.releaseFrames); - level_ = releaseFrom_ * (1.0 - t); - if (level_ < 0.0) level_ = 0.0; + level_ = stageLevel(params_); const double out = level_; - ++framesInStage_; - if (framesInStage_ >= params_.releaseFrames) { + stagePos_ += 1.0; + if (stagePos_ >= static_cast(params_.releaseFrames)) { stage_ = Stage::Finished; level_ = 0.0; } @@ -145,16 +268,12 @@ public: return 0.0; // unreachable; silences a warning. } - Stage stage() const { return stage_; } - bool finished() const { return stage_ == Stage::Finished; } - double level() const { return level_; } - -private: AdsrParams params_; Stage stage_ = Stage::Idle; double level_ = 0.0; - std::int64_t framesInStage_ = 0; + double stagePos_ = 0.0; double releaseFrom_ = 0.0; // level at the moment noteOff() was called + StepSmoother smooth_; }; // A stateless-shape amplitude function over the play span, evaluated at a source-frame @@ -233,34 +352,71 @@ private: // (Varispeed) or a shift-amount add (Preserve). class PitchEnvelope { public: - void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } - void noteOn() { pos_ = 0; } + void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; } + void noteOn() { pos_ = 0.0; smooth_.clear(); } + + // Peer of AdsrEnvelope::snapLive (see it for why the two paths cannot share code): a voice + // that has rendered nothing takes the new times and depth outright. + void snapLive(std::int64_t attackFrames, std::int64_t decayFrames, double peakSemitones) { + params_.attackFrames = attackFrames; + params_.decayFrames = decayFrames; + params_.peakSemitones = peakSemitones; + smooth_.clear(); + } + + // Live parameter delivery, same rule as AdsrEnvelope::applyLive: hold the normalized + // position within whichever leg the envelope is in, and absorb the depth step (peak is a + // level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is + // deliberately not a parameter here. + void applyLive(std::int64_t attackFrames, std::int64_t decayFrames, + double peakSemitones) { + const double before = offsetAt(params_); + const double a = params_.attackFrames > 0 ? static_cast(params_.attackFrames) : 0.0; + const double d = params_.decayFrames > 0 ? static_cast(params_.decayFrames) : 0.0; + const double na = attackFrames > 0 ? static_cast(attackFrames) : 0.0; + const double nd = decayFrames > 0 ? static_cast(decayFrames) : 0.0; + if (pos_ < a) { + pos_ = (na > 0.0) ? pos_ * (na / a) : na; + } else if (pos_ < a + d) { + pos_ = (nd > 0.0) ? na + (pos_ - a) * (nd / d) : na + nd; + } else { + pos_ = na + nd; // already past the envelope: stay past it under the new times + } + params_.attackFrames = attackFrames; + params_.decayFrames = decayFrames; + params_.peakSemitones = peakSemitones; + const double after = offsetAt(params_); + if (after != before) smooth_.absorb(before - after); + } double tick() { if (!params_.enabled) return 0.0; - - const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0; - const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0; - const double peak = params_.peakSemitones; - - double offset; - if (pos_ < a) { - // Attack: 0 -> peak over attackFrames (rise into the peak). - offset = peak * (static_cast(pos_) / static_cast(a)); - } else if (pos_ < a + d) { - // Decay: peak -> 0 over decayFrames (settle to base pitch). - const double t = static_cast(pos_ - a) / static_cast(d); - offset = peak * (1.0 - t); - } else { - offset = 0.0; // past attack+decay: at base pitch forever. - } - ++pos_; - return offset; + const double offset = offsetAt(params_); + pos_ += 1.0; + return smooth_.active() ? offset + smooth_.advance() : offset; } private: + // The semitone offset at the current position under `params` — the shared evaluator for + // both tick() and applyLive's before/after comparison. + double offsetAt(const PitchEnvParams& params) const { + if (!params.enabled) return 0.0; + const double a = params.attackFrames > 0 ? static_cast(params.attackFrames) : 0.0; + const double d = params.decayFrames > 0 ? static_cast(params.decayFrames) : 0.0; + if (pos_ < a) { + // Attack: 0 -> peak over attackFrames (rise into the peak). + return params.peakSemitones * (pos_ / a); + } + if (pos_ < a + d) { + // Decay: peak -> 0 over decayFrames (settle to base pitch). + return params.peakSemitones * (1.0 - (pos_ - a) / d); + } + return 0.0; // past attack+decay: at base pitch forever. + } + PitchEnvParams params_; - std::int64_t pos_ = 0; + double pos_ = 0.0; + StepSmoother smooth_; }; } // namespace reasampler diff --git a/src/core/instrument/engine/filter/CLAUDE.md b/src/core/instrument/engine/filter/CLAUDE.md index 4d22dd4..e282455 100644 --- a/src/core/instrument/engine/filter/CLAUDE.md +++ b/src/core/instrument/engine/filter/CLAUDE.md @@ -234,6 +234,13 @@ topology. genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance either way. Do not reintroduce a direct-form kernel. +- **A coefficient jump here produces no isolated output spike, measured.** Preserving state + across `prepare()` is strong enough that even an instantaneous cutoff/Q/morph jump leaves the + boundary frame inside the signal's own frame-to-frame range — a single-frame-spike metric + cannot detect one. What the caller's per-frame glide prevents is therefore the *parameter* + arriving as a step (and the zipper of repeated steps at control rate), not a click at the + jump itself. A test claiming to prove the glide must measure how fast the output diverges, + not how far one frame moves; `live_delivery_tests` does. - **`prepare()` deliberately does not clear state** — a live parameter move must glide, not click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There, `a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads diff --git a/src/core/instrument/engine/live_params.cpp b/src/core/instrument/engine/live_params.cpp new file mode 100644 index 0000000..70cedc1 --- /dev/null +++ b/src/core/instrument/engine/live_params.cpp @@ -0,0 +1,26 @@ +// live_params.cpp — the fold from the parameter set to the live block, and the ramp-step law. +// See live_params.h for the publication contract. + +#include "core/instrument/engine/live_params.h" + +namespace reasampler::instrument::engine { + +LiveValues foldLive(const PlayParams& params) { + LiveValues v; + v.filterSettings = params.filter.settings; + v.filterModAmount = params.filter.modAmount; + v.filterKeyTrack = params.filter.keyTrack; + v.filterEnv = params.filter.env; + v.adsr = params.adsr; + v.pitchEnvAttackFrames = params.pitchEnv.attackFrames; + v.pitchEnvDecayFrames = params.pitchEnv.decayFrames; + v.pitchEnvPeakSemitones = params.pitchEnv.peakSemitones; + return v; +} + +double liveRampStep(double sampleRate) { + if (!(sampleRate > 0.0)) return 0.0; // also catches NaN + return 1.0 / (kLiveRampSeconds * sampleRate); +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h new file mode 100644 index 0000000..bfb7941 --- /dev/null +++ b/src/core/instrument/engine/live_params.h @@ -0,0 +1,131 @@ +#pragma once +// live_params.h — the live playback-parameter block: the plain value bundle the audio thread +// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a +// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the +// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every +// instrument snapshot (see SampleData::live). + +#include +#include +#include + +#include "core/instrument/engine/play_params.h" + +namespace reasampler::instrument::engine { + +// Full-scale glide time for a live control move (wall-clock seconds), so every smoothed +// control in the program settles on the one time base the post-mixer gain ramp already uses. +inline constexpr double kLiveRampSeconds = 0.020; + +// Every continuously-valued playback control, in the SAME domains the engine latches at +// note-on (normalized control positions, envelope times already resolved to frames). What is +// deliberately absent is as load-bearing as what is present: velocity and everything derived +// from it, the note number and its pitch ratio, and the decoded PCM are facts about the note +// event, not controls, and stay latched at note-on. The discrete toggles (play mode, pitch +// engine, filter enable/law, pitch-envelope enable, channel mode) travel by reload instead. +// +// morphLaw rides inside filterSettings only because it is cheaper to carry the whole struct to +// the filter's prepare() than to splice it back; it changes only across a reload, which +// republishes this block, so the two can never disagree. +struct LiveValues { + filter::FilterSettings filterSettings{}; + double filterModAmount = 0.0; + double filterKeyTrack = 0.0; + AdsrParams filterEnv{}; + AdsrParams adsr{}; + std::int64_t pitchEnvAttackFrames = 0; + std::int64_t pitchEnvDecayFrames = 0; + double pitchEnvPeakSemitones = 0.0; +}; + +// The seqlock copies the block as raw bytes, which is only defensible for a plain value type. +static_assert(std::is_trivially_copyable_v, + "the live block is copied under a seqlock — it must stay a plain value"); + +// The ONE derivation of the live block from the parameter set. Every publisher goes through +// here so there is a single site to keep in step with PlayParams. +LiveValues foldLive(const PlayParams& params); + +// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd +// and an even generation; the reader copies the block and re-checks the generation, retrying +// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the +// reader: after the retry budget it reports "nothing new" and the caller keeps its last good +// snapshot rather than spinning on the audio thread. +// +// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the +// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the +// block, and a reader then accepts a torn block as coherent. Every publisher must serialize. +// +// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock: +// the fences give correct ordering, but the concurrent read of a non-atomic object is a data +// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not +// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain +// value the audio thread can copy in one shot. +// +// The writer interface deliberately assumes NO particular thread beyond single-writer, so a +// host's own parameter-change queue (delivered on the audio thread with sample offsets) can +// drive it later without a redesign. +class LiveParams { +public: + // A generation of 0 means "never published"; the first publish lands on 2. + void publish(const LiveValues& values) { + const std::uint32_t gen = seq_.load(std::memory_order_relaxed); + seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress + std::atomic_thread_fence(std::memory_order_release); + values_ = values; + std::atomic_thread_fence(std::memory_order_release); + // Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and + // stall every reader until the NEXT publish — a silent mode, unlike a loud one. + const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2; + seq_.store(next, std::memory_order_release); // even: complete and coherent + } + + // Copies the block into `out` and returns the generation actually observed, or 0 when + // nothing has been published yet or the retry budget ran out (in which case `out` may hold + // a torn copy and MUST be discarded — compare the return against 0 before using it). + std::uint32_t read(LiveValues& out, int maxAttempts = 4) const { + for (int attempt = 0; attempt < maxAttempts; ++attempt) { + const std::uint32_t before = seq_.load(std::memory_order_acquire); + if (before == 0) return 0; // never published + if ((before & 1u) != 0u) continue; // writer mid-update + std::atomic_thread_fence(std::memory_order_acquire); + out = values_; + std::atomic_thread_fence(std::memory_order_acquire); + if (seq_.load(std::memory_order_relaxed) == before) return before; + } + return 0; + } + +private: + std::atomic seq_{0}; + LiveValues values_{}; +}; + +// Linear per-frame glide with EXACT termination: once the target is within one step the value +// becomes the target itself. An asymptotic smoother would leave the value forever a hair off, +// pinning the filter's exact-equality cutoff skip on the always-re-solve path; this returns to +// the skip path the moment the move completes. A non-positive step snaps (no rate known yet). +struct ValueRamp { + double value = 0.0; + double target = 0.0; + double step = 0.0; + + bool moving() const { return value != target; } + void set(double v) { value = v; target = v; } + void aim(double t) { target = t; } + + // Advances one frame; returns whether the value actually moved. + bool tick() { + if (value == target) return false; + const double delta = target - value; + if (step <= 0.0 || (delta <= step && delta >= -step)) value = target; + else value += (delta > 0.0) ? step : -step; + return true; + } +}; + +// Per-frame ramp step for a control whose full travel is 1.0, at `sampleRate`. A non-positive +// rate yields 0 — the ramp then snaps rather than inventing a rate. +double liveRampStep(double sampleRate); + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index 433d62a..8a4d910 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -14,6 +14,8 @@ namespace reasampler { +namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer + using audio::AudioSample; using instrument::engine::VelocityCurve; @@ -166,6 +168,14 @@ struct SampleData { PlayParams play; + // The live-parameter block a sounding voice tracks, or null for the bare latched engine + // (the default — with no block attached the core is byte-identical to the pre-live one). + // NON-OWNING and deliberately not per-snapshot: the shell owns ONE block that outlives + // every instrument snapshot, so a voice still ringing out of the drain slot follows the + // same knob as a live one. That is the desired behaviour — it is the note the user is + // hearing. Do not "fix" it by moving ownership into the snapshot. + const instrument::engine::LiveParams* live = nullptr; + // A framesR of a different length than frames is treated as absent — a malformed pair // never half-plays. int channelCount() const { diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 5b9f834..b04066c 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -92,24 +92,41 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick pitchEnv_.configure(p.pitchEnv); pitchEnv_.noteOn(); + // A restart lands every live glide back on the new note's own values, at a step derived + // from this sample's rate rather than any assumed one. + filterRamping_ = false; + const double rampStep = instrument::engine::liveRampStep( + static_cast(sample.sampleRate)); + rBaseCutoff_.step = rampStep; + rModAmount_.step = rampStep; + rResonance_.step = rampStep; + rMorph_.step = rampStep; + rDrive_.step = rampStep; + // Filter: reset() clears integrator state for the new note (prepare() preserves it — // voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the // per-frame path, exactly as the amp's velocityGain_ does. filterOn_ = p.filter.enabled; if (filterOn_) { + filterSettings_ = p.filter.settings; filterCutoffNorm_ = static_cast(p.filter.settings.cutoffNorm); filterModAmount_ = p.filter.modAmount; filterKeyTrack_ = p.filter.keyTrack; filterVelOffset_ = p.filter.velAmount * p.filter.velocityCurve.eval(static_cast(velocity)); filterRate_ = static_cast(sample.sampleRate); + rModAmount_.set(p.filter.modAmount); + rResonance_.set(static_cast(p.filter.settings.resonanceNorm)); + rMorph_.set(static_cast(p.filter.settings.morphNorm)); + rDrive_.set(static_cast(p.filter.settings.driveNorm)); filterEnv_.configure(p.filter.env); filterEnv_.noteOn(); filter_.reset(); updateFilterCutoffBase(note); - // The note's ONE full solve — Q, morph and drive are constants for its lifetime, so - // every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this - // cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel. + // The note's ONE full solve — Q, morph and drive are constants for its lifetime unless + // a live move glides them, so every later re-solve is the cheap cutoff-only path. A + // modulated voice supersedes this cutoff in tickFilterCutoff on its first frame, + // before any sample reaches the kernel. instrument::engine::filter::FilterSettings s = p.filter.settings; s.cutoffNorm = filterBaseCutoff_; filter_.prepare(s, filterRate_); @@ -178,6 +195,51 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine. } +void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { + // Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span + // resolved at note-on and travels by reload instead (deck_groups.h names why). + // + // A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a + // flag: a voice that has rendered nothing has no phase to hold and nothing to be + // continuous with, and the mid-stage rule misreads its stage-0 position (envelopes.h). + if (snap) { + if (playMode_ == PlayMode::Gate) env_.snapLive(live.adsr); + pitchEnv_.snapLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames, + live.pitchEnvPeakSemitones); + } else { + if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr); + pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames, + live.pitchEnvPeakSemitones); + } + if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload + + if (snap) filterEnv_.snapLive(live.filterEnv); + else filterEnv_.applyLive(live.filterEnv); + filterCutoffNorm_ = static_cast(live.filterSettings.cutoffNorm); + filterKeyTrack_ = live.filterKeyTrack; + filterSettings_.morphLaw = live.filterSettings.morphLaw; + const double baseTarget = filterCutoffBaseTarget(note_); + if (snap) { + rBaseCutoff_.set(baseTarget); + rModAmount_.set(live.filterModAmount); + rResonance_.set(static_cast(live.filterSettings.resonanceNorm)); + rMorph_.set(static_cast(live.filterSettings.morphNorm)); + rDrive_.set(static_cast(live.filterSettings.driveNorm)); + filterBaseCutoff_ = static_cast(baseTarget); + filterModAmount_ = live.filterModAmount; + filterRamping_ = false; + prepareFilterFromRamps(); + return; + } + rBaseCutoff_.aim(baseTarget); + rModAmount_.aim(live.filterModAmount); + rResonance_.aim(static_cast(live.filterSettings.resonanceNorm)); + rMorph_.aim(static_cast(live.filterSettings.morphNorm)); + rDrive_.aim(static_cast(live.filterSettings.driveNorm)); + filterRamping_ = rBaseCutoff_.moving() || rModAmount_.moving() || rResonance_.moving() || + rMorph_.moving() || rDrive_.moving(); +} + void Voice::retune(int note) { // Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps // running (no re-attack), the read head keeps its position, the shifter keeps its ring diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 435573d..58b6496 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -15,6 +15,7 @@ #include "core/instrument/engine/envelopes.h" #include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/voice_filter.h" +#include "core/instrument/engine/live_params.h" #include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/velocity_curve.h" @@ -115,6 +116,16 @@ public: // meaningful while active(). PitchEngine pitchEngine() const { return pitchEngine_; } + // Applies the live-parameter block to a voice that is already sounding (or, with `snap`, + // to one just started). Called at BLOCK boundaries by VoiceEngine — never per frame — so + // the per-sample shape is unchanged; every continuous control glides toward its new value + // from here rather than jumping to it. `snap` takes the values outright — glides AND + // envelopes: a fresh note has nothing to glide from, and its copy may predate the edit. + // + // What is NOT here is the point: velocity and its curve result, the note number and the + // pitch ratio, and the decoded PCM stay latched at note-on. + void applyLive(const instrument::engine::LiveValues& live, bool snap); + // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which // runs inside process() — never allocates. <= 1 leaves the shifters pass-through. @@ -196,9 +207,10 @@ private: } // The cutoff position before the envelope: the stored knob position plus this note's - // velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both - // move the note), never per frame. - void updateFilterCutoffBase(int note) { + // velocity offset and key-tracking. Evaluated at note-on, at a legato retune (both move + // the note), and when a live move changes the knob position or the key-track depth — + // never per frame. + double filterCutoffBaseTarget(int note) const { double base = filterCutoffNorm_ + filterVelOffset_; if (filterKeyTrack_ != 0.0 && sample_ != nullptr) { base += filterKeyTrack_ * @@ -207,10 +219,52 @@ private: } if (base < 0.0) base = 0.0; if (base > 1.0) base = 1.0; + return base; + } + + // Takes the base outright (no glide) — a note-on or a retune is a new note position, not a + // knob move, so there is nothing to glide from. + void updateFilterCutoffBase(int note) { + const double base = filterCutoffBaseTarget(note); + rBaseCutoff_.set(base); filterBaseCutoff_ = static_cast(base); filterSolved_ = false; // forces the next frame to solve } + // The full solve, from the tone-control ramps' current values, at the current base cutoff — + // the same shape start() performs, and it leaves the same solved-cutoff bookkeeping behind + // so an unmoved live block reproduces start()'s state exactly. State is preserved across + // prepare() by contract (voice_filter.h), which is what makes a live tone move glide + // rather than click. + void prepareFilterFromRamps() { + filterSettings_.resonanceNorm = static_cast(rResonance_.value); + filterSettings_.morphNorm = static_cast(rMorph_.value); + filterSettings_.driveNorm = static_cast(rDrive_.value); + filterSettings_.cutoffNorm = filterBaseCutoff_; + filter_.prepare(filterSettings_, filterRate_); + filterSolvedCutoff_ = filterBaseCutoff_; + filterSolved_ = true; + } + + // Advances the five live filter-control glides by one frame. Q, morph and drive are + // prepare()-cadence constants, so a move on any of them costs the full solve while the + // glide runs (~20 ms) and nothing once it lands; the base cutoff and the mod depth feed + // tickFilterCutoff's own cheap cutoff-only solve instead. + void tickFilterRamps() { + bool tone = false; + if (rResonance_.tick()) tone = true; + if (rMorph_.tick()) tone = true; + if (rDrive_.tick()) tone = true; + if (rBaseCutoff_.tick()) { + filterBaseCutoff_ = static_cast(rBaseCutoff_.value); + filterSolved_ = false; + } + if (rModAmount_.tick()) filterModAmount_ = rModAmount_.value; + if (tone) prepareFilterFromRamps(); + filterRamping_ = rResonance_.moving() || rMorph_.moving() || rDrive_.moving() || + rBaseCutoff_.moving() || rModAmount_.moving(); + } + // Seeds the takeover compensation on the first frame after a restart: the ramp is the // actual discontinuity — (pre-cut reference - the new voice's raw output this frame) — // applied ungated so the boundary frame reproduces the old level exactly. @@ -397,6 +451,7 @@ private: // Skipped whole when disengaged (the default), so an un-filtered render stays // bit-identical to the pre-filter engine. if (filterOn_) { + if (filterRamping_) tickFilterRamps(); // false at rest: one predicted branch tickFilterCutoff(); outL = static_cast(filter_.process(0, static_cast(outL))); // Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the @@ -486,10 +541,24 @@ private: double filterModAmount_ = 0.0; double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note double filterKeyTrack_ = 0.0; + instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from bool filterSolved_ = false; // false forces the next frame to solve + // Live-parameter glides (live_params.h). Every one is parked at its target unless a move + // is in flight, so filterRamping_ is false and the per-sample path keeps the pre-live + // engine's exact shape. All five live in the filter's control domains — the envelopes + // need no ramp here, because holding normalized stage position is continuous by + // construction and their two genuine level steps are absorbed inside AdsrEnvelope / + // PitchEnvelope themselves. + bool filterRamping_ = false; + instrument::engine::ValueRamp rBaseCutoff_; + instrument::engine::ValueRamp rModAmount_; + instrument::engine::ValueRamp rResonance_; + instrument::engine::ValueRamp rMorph_; + instrument::engine::ValueRamp rDrive_; + // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter). // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine. // diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp index 34e16fb..9c106c5 100644 --- a/src/core/instrument/engine/voice_engine.cpp +++ b/src/core/instrument/engine/voice_engine.cpp @@ -33,6 +33,32 @@ VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample, } } +bool VoiceEngine::refreshLive() { + const instrument::engine::LiveParams* block = sample_.live; + if (block == nullptr) return false; // bare engine: the latched note-on values stand + instrument::engine::LiveValues observed; + const std::uint32_t generation = block->read(observed); + if (generation == 0 || generation == liveGeneration_) return false; + liveGeneration_ = generation; + live_ = observed; + haveLive_ = true; + return true; +} + +void VoiceEngine::applyLiveToActive() { + if (!refreshLive()) return; + for (Voice& voice : voices_) { + if (voice.active()) voice.applyLive(live_, /*snap=*/false); + } +} + +void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { + refreshLive(); + voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); + if (haveLive_) voice.applyLive(live_, /*snap=*/true); + voice.setStartOrder(nextStartOrder_++); +} + std::size_t VoiceEngine::activePreserveVoices() const { // Count only voices that are SOUNDING A NOTE (playable span still running), not voices // that have finished their note but are still ringing out a declick tail. A ramp-only @@ -122,8 +148,7 @@ std::size_t VoiceEngine::monoNoteOn(int note, int velocity) { // RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in // rides every mono restart; start() self-gates it on the voice being ACTIVE, so a // first-note fresh start never ramps — only a hard cut of a sounding tone. - v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); - v.setStartOrder(nextStartOrder_++); + startVoice(v, note, velocity); return 0; } @@ -150,8 +175,7 @@ void VoiceEngine::monoNoteOff(int note) { // Retrigger fallback: re-strike the fallen-back-to note at its own original velocity. // Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also // hard-cuts the sounding tone). - v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_); - v.setStartOrder(nextStartOrder_++); + startVoice(v, fb.note, fb.velocity); } std::size_t VoiceEngine::noteOn(int note, int velocity) { @@ -174,8 +198,7 @@ std::size_t VoiceEngine::noteOn(int note, int velocity) { // active, so a free-voice start never ramps — only an at-cap steal, which is the same hard // cut of a sounding tone as the mono retrig takeover. const std::size_t v = allocateVoice(); - voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_); - voices_[v].setStartOrder(nextStartOrder_++); + startVoice(voices_[v], note, velocity); return v; } @@ -225,6 +248,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { // The VST3 process callback hands us the host's output channel buffer here, so the // audio thread never touches the heap. if (out == nullptr || frameCount == 0) return; + applyLiveToActive(); // block boundary, once — never inside the frame loop for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { @@ -240,6 +264,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram // iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame). if (left == nullptr || right == nullptr || frameCount == 0) return; + applyLiveToActive(); // block boundary, once — never inside the frame loop for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { diff --git a/src/core/instrument/engine/voice_engine.h b/src/core/instrument/engine/voice_engine.h index 1629ead..e06995a 100644 --- a/src/core/instrument/engine/voice_engine.h +++ b/src/core/instrument/engine/voice_engine.h @@ -10,6 +10,7 @@ #include #include "core/audio/peaks.h" +#include "core/instrument/engine/live_params.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/voice.h" @@ -106,6 +107,23 @@ private: // one per the documented policy. Always returns a valid index (maxVoices >= 1). std::size_t allocateVoice(); + // --- Live-parameter observation (live_params.h) --- + // The ONE place the seqlock is read: at block start and at each note-on, on the audio + // thread, never per frame. A torn or never-published read leaves the last good snapshot + // in place rather than spinning. Returns whether a NEW generation landed. + bool refreshLive(); + // Block-boundary refresh: pushes a newly-observed generation into every sounding voice, + // which glides toward it. No-op when nothing changed (and when no block is attached). + void applyLiveToActive(); + // The one restart path: start the voice, hand it the live values outright (it has nothing + // to glide from), and stamp its age. Shared by the poly steal and both mono restarts so + // no restart site can miss the live handoff. + void startVoice(Voice& voice, int note, int velocity); + + instrument::engine::LiveValues live_{}; + std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet + bool haveLive_ = false; + // Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index bfb266b..8f144be 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -97,4 +97,62 @@ std::vector sampleDeckGroups(PlayMode playMode) { return out; } +bool isLiveDeckParam(DeckParam id) { + switch (id) { + case DeckParam::kAttack: + case DeckParam::kHold: + case DeckParam::kDecay: + case DeckParam::kSustain: + case DeckParam::kRelease: + case DeckParam::kPitchEnvAttack: + case DeckParam::kPitchEnvDecay: + case DeckParam::kPitchEnvDepth: + case DeckParam::kFilterMorph: + case DeckParam::kFilterCutoff: + case DeckParam::kFilterQ: + case DeckParam::kFilterDrive: + case DeckParam::kFilterModAmt: + case DeckParam::kFilterKeyTrack: + case DeckParam::kFilterEnvAttack: + case DeckParam::kFilterEnvHold: + case DeckParam::kFilterEnvDecay: + case DeckParam::kFilterEnvSustain: + case DeckParam::kFilterEnvRelease: + return true; + // Listed rather than defaulted so a newly added control is a COMPILE error here (the + // -Wswitch gate is GCC/Clang; MSVC's C4062 is off at this project's warning level) + // instead of silently defaulting to non-live. Reasons live in the header. + case DeckParam::kPlayMode: + case DeckParam::kPitchEngine: + case DeckParam::kTrigLength: + case DeckParam::kTrigFadeIn: + case DeckParam::kTrigFadeOut: + case DeckParam::kPitchEnvEnable: + case DeckParam::kKeyTrack: + case DeckParam::kFilterEnable: + case DeckParam::kFilterVel: + case DeckParam::kFilterLaw: + case DeckParam::kVoiceCount: + case DeckParam::kVoiceMode: + case DeckParam::kMonoTrigger: + case DeckParam::kMasterGain: + case DeckParam::kCount: // not a control + return false; + } + return false; // unreachable for a valid enumerator; silences a warning. +} + +bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode) { + switch (kind) { + case LiveDragKind::kDeckKnob: + return paramId >= 0 && paramId < static_cast(DeckParam::kCount) && + isLiveDeckParam(static_cast(paramId)); + case LiveDragKind::kEnvNode: + return playMode == PlayMode::Gate; + case LiveDragKind::kOther: + return false; + } + return false; +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 69256d6..8974dcd 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -72,6 +72,40 @@ enum DeckGroupId { // reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups. std::vector sampleDeckGroups(PlayMode playMode); +// Whether control `id` is delivered LIVE — straight to the voices that are already sounding — +// rather than through an instrument reload. The line is drawn at continuously-valued playback +// controls, so this is a routing decision at the editor's commit site rather than a property +// of any one knob; moving a control across the line is a change here and nowhere else. +// +// THE home for why each excluded control is excluded. Five continuous controls are outside the +// live set, plus every discrete toggle: +// - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable) +// name a different sound rather than a different setting of one; +// - the three capture-anchored overrides (root, loop span, start frame) name positions in +// the decoded PCM; +// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch +// ratio and the velocity-curve result), so live delivery would retune or re-gain a note +// already struck; +// - kTrigLength resolves playEnd_, a fact about the note. kTrigFadeIn/kTrigFadeOut are pure +// amplitude shape and would be live-able in principle, but they live in `sample.play` and +// are baked into SampleData at build time — the engine rebuild copies that verbatim, so +// only a reload can deliver them without widening LiveValues. They fold into the AHD +// alongside Gate's, at which point they inherit its routing; until then they reload. +// Consequence, stated plainly: a Trigger-mode instance gets NO live delivery on its amplitude +// controls. Only the filter and pitch-envelope knobs move a sounding Trigger one-shot. +bool isLiveDeckParam(DeckParam id); + +// The editor drag kinds that can commit live, in this pure module's own vocabulary (the +// shell's DragKind maps onto it) so the WHOLE routing decision — not just the predicate — is +// testable without a host. +enum class LiveDragKind { kOther, kDeckKnob, kEnvNode }; + +// Whether a drag of `kind` commits live. A deck knob is live per isLiveDeckParam (negative ids +// are the shell's processor-side sentinels and out-of-range ids are not controls, so neither +// reaches the enum); an envelope-node drag is live only in Gate, where it edits the AHDSR — +// in Trigger the same drag rewrites the play span, which is not a live control. +bool liveCommitFor(LiveDragKind kind, int paramId, PlayMode playMode); + // The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and // +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at // centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints. diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 75ac742..7d66eea 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -67,6 +67,20 @@ scattered `#ifdef`s in the VST shell, except the one described below). - **Verify** all identity/factory wiring against the vendored Steinberg SDK (`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`). +**The three commit tiers (Θ-W3).** An edit reaches the audio by exactly one of three routes, and +which route a control takes is decided once, by the pure `isLiveDeckParam` / `liveCommitFor` pair +(`core/instrument/ui/deck_groups`) that the editor's `dragCommitsLive` only maps onto — see +`core/instrument/CLAUDE.md`'s "Live parameter delivery" for the rule and its rationale. +1. **Full reload** — `reloadInstrument`: bridge read, WAV re-decode, fresh engine, snapshot swap. +2. **Engine rebuild** — `rebuildVoiceEngine`: same drain-slot swap around the already-decoded + `SampleData`. Voice count / mode / mono trigger. +3. **Live** — `publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free + publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk. + +The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the +parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is +declared ahead of the instrument slots at that member in `reasampler_processor.h`. + **Non-goals / guardrails.** - The instrument never captures and never inserts into the arrange. Playback is a read-only act over the bank. Any instrument path that captures, places a timeline diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index 38ad2f6..322db2e 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -104,6 +104,13 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { invalidate(); return; } + // A live control already reached the voices during the drag; its release commits the + // final value through the same tier. + if (dragCommitsLive(kind, paramId)) { + commitLive(); + invalidate(); + return; + } // Drag-off delete: releasing a curve-node drag well outside the box removes the dragged // point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move — // its amp keeps the last clamped drag value). diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 4c109ce..e7ce840 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -107,6 +107,9 @@ void ReaSamplerEditor::dragDeck(int x, int y) { // grab. Live feedback; parameter-set commits land on WM_LBUTTONUP. (void)x; applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_)); + // A live control is delivered on every move, not only on release — that is the whole + // point: the note already sounding tracks the hand on the knob. + if (dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) commitLive(); invalidate(); } diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 0fa86c2..b2b60dc 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -78,6 +78,9 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, overlay, totalSeconds, envClampBounds(), dx, y - dragStartY_); unpackEnvelope(edited, frames, dragStartFrame_, params_.play); + // In Gate the node IS a live AHDSR control, so the sounding note follows the drag; + // Trigger's nodes rewrite the play span and still commit on release. + if (dragCommitsLive(DragKind::kEnvNode)) commitLive(); invalidate(); // live feedback; commit on WM_LBUTTONUP return; } diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index 0d6d2c5..7c2e077 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -205,8 +205,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, case WM_RBUTTONUP: return 0; // claimed so the pair never reaches DefWindowProc (no context menu) case WM_CAPTURECHANGED: - // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its - // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore params_ to its + // pre-grab snapshot so the in-flight drag mutation is rolled back, then reset // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. // Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp). if (self) { @@ -219,15 +219,29 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, } if (self->drag_ != DragKind::kNone) { // A scrollbar drag + the processor-side deck knobs (preview velocity -2 / - // voice count / master gain) are transient (they mutate no parameter, so - // dragStartParams_ is not a rollback target) — reset drag state only. - // Every parameter-editing drag rolls its live mutation back to the snapshot. + // voice count / master gain) mutate no params_ field, so dragStartParams_ + // is not a rollback target for them — reset drag state only. Every + // params_-editing drag restores the pre-grab snapshot. Voice count and + // master gain are pre-existing exceptions to that: both write straight + // through on every move (editor voiceCount_ / processor masterGain_) + // rather than through params_, so an abandoned drag leaves them at the + // abandoned value indefinitely instead of rolling back. const bool transient = self->drag_ == DragKind::kScrollThumb || (self->drag_ == DragKind::kDeckKnob && (self->dragParamId_ == -2 || self->dragParamId_ == static_cast(ParamControl::kVoiceCount) || self->dragParamId_ == static_cast(ParamControl::kMasterGain))); - if (!transient) self->params_ = self->dragStartParams_; + if (!transient) { + self->params_ = self->dragStartParams_; + // A live drag already reached the voices AND the processor's own + // parameter set on every move, so restoring params_ alone would leave + // the face painting one value while the audio plays — and getState + // persists — the abandoned one. Roll back through the same tier the + // drag used. + if (self->dragCommitsLive(self->drag_, self->dragParamId_)) { + self->commitLive(); + } + } self->drag_ = DragKind::kNone; self->dragParamId_ = -1; self->curvePointIndex_ = -1; // curve-node drag state (peer reset) diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 0fbabbd..fc50a46 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -136,6 +136,24 @@ void ReaSamplerEditor::commitAndReload() { #endif } +void ReaSamplerEditor::commitLive() { + // UI thread only. See the declaration for why this still writes the parameter set. + if (!processor_) return; + processor_->setInstrumentParams(params_); + processor_->publishLiveParams(); +} + +bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const { + // The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind + // vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than + // by inspection of this file. + using instrument::ui::LiveDragKind; + const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob + : kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode + : LiveDragKind::kOther; + return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode); +} + void ReaSamplerEditor::loadSelection(const std::string& id) { // A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch // engine, key-track, velocity curve) are NOT reset — the one set governs whatever is diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index c34a1b3..84559e4 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -148,7 +148,21 @@ std::string ReaSamplerProcessor::reloadInstrument() { if (pcm) { sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm)); havePlayable = sample.playable(); - if (havePlayable) resolvedId = selId; // the concrete pick that resolved + if (havePlayable) { + // Point the built snapshot at the instance's ONE live block and seed it from + // the very PlayParams the voices latch, so an untouched knob folds to the same + // frames the build resolved and a note-on with a live block sounds identical + // to one without. + sample.live = &liveParams_; + { + // reloadMutex_ (held for this whole function) nests livePublishMutex_ here; + // publishLiveParams never holds reloadMutex_, so this is the only nesting. + std::lock_guard lp(livePublishMutex_); + liveParams_.publish(instrument::engine::foldLive(sample.play)); + } + builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed); + resolvedId = selId; // the concrete pick that resolved + } } } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 1d7ea10..112decf 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -152,6 +152,17 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { params_ = params; } +void ReaSamplerProcessor::publishLiveParams() { + const int rate = builtSampleRate_.load(std::memory_order_relaxed); + if (rate <= 0) return; + const instrument::engine::LiveValues block = + instrument::engine::foldLive(resolvePlay(instrumentParams().play, rate)); + // livePublishMutex_ enforces the seqlock's single-writer contract (live_params.h) against + // reloadInstrument's publish — held for the publish call only, not the fold above. + std::lock_guard lock(livePublishMutex_); + liveParams_.publish(block); +} + SampleRefs ReaSamplerProcessor::sampleRefs() { std::lock_guard lock(refsMutex_); return sampleRefs_; diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 74d5b39..a6c7579 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -233,6 +233,17 @@ private: // instrument off the audio thread. UI thread only. void commitAndReload(); + // The live peer of commitAndReload for a continuously-valued control (isLiveDeckParam): + // the same parameter-set write — so a saved project carries the edit exactly as before — + // followed by a live publish instead of a rebuild, so the note already sounding follows + // the knob. Does not repaint; callers already do. UI thread only. + void commitLive(); + + // Whether an in-flight drag commits live rather than through a reload. A deck knob is + // live per isLiveDeckParam; an envelope-node drag is live only in Gate, where it edits the + // AHDSR — in Trigger the same drag rewrites the play span, which is not a live control. + bool dragCommitsLive(DragKind kind, int paramId = -1) const; + // Commits `id` as the loaded capture. The one parameter set carries over — it governs // whatever is loaded, so a load swaps the sound, not the settings. void loadSelection(const std::string& id); diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 568f166..3e0f7b9 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -19,6 +19,7 @@ #include "shell/instrument/reaper_bridge.h" #include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #include "core/instrument/map/component_state_io.h" // ComponentState codec +#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/voice_engine.h" namespace reasampler::vst { @@ -151,6 +152,14 @@ public: InstrumentParams instrumentParams(); void setInstrumentParams(const InstrumentParams& params); + // Republishes the live-parameter block from the stored parameter set, resolved against the + // rate the loaded capture was built at so an unmoved value folds to exactly the frames the + // voices already latched. THE tier-3 commit (the three tiers are listed in this + // directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they + // paired it with reloadInstrument. No-op before anything has been decoded (the next reload + // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. + void publishLiveParams(); + // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is // fixed stereo, so a mode change never renegotiates host I/O. @@ -225,6 +234,28 @@ private: ReaperBridge bridge_; + // The ONE live-parameter block for this instance, declared ahead of the instrument slots + // so it outlives every snapshot that points at it (members destruct in reverse order). + // Both live_ and draining_ observe this same block — a block owned by a snapshot would + // leave the drain's still-sounding voices deaf to the knob under them. + instrument::engine::LiveParams liveParams_; + // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) + // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a + // reload's WAV decode. The audio thread never takes this; process() only reads via + // LiveParams::read's lock-free seqlock retry. + std::mutex livePublishMutex_; + // The rate the loaded capture was decoded/built at, so a live republish resolves the + // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing + // built yet. + // + // ONE BLOCK, ONE RATE: this is stamped by whichever capture built last, and a reload + // publishes the new block before installing the new instrument. Swapping to a capture at a + // different rate therefore hands drain voices still ringing from the old-rate capture + // envelope frame counts resolved at the NEW rate (~8.8% timing shift on a 48k->44.1k swap). + // Unavoidable while one block sits above every snapshot, and it touches a release tail + // only. + std::atomic builtSampleRate_{0}; + // --- The audio-thread handoff (drain slot) --- // process() atomically loads live_ + draining_ at block start (two acquires, no lock). // reloadInstrument() (off-thread, serialized by reloadMutex_) swaps a new build into diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index c4fc5f9..f0994ff 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -2,8 +2,9 @@ // framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // the Filter group's contents, the wrapped deck height at the editor's floor width and its fit -// inside the floor window, the hit-test reaching the new filter controls, and the bipolar knob -// law's inverse pair. +// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob +// law's inverse pair, and the commit-tier routing — which controls are live, and which drags +// take the live tier. #include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/sample_bands.h" @@ -186,7 +187,74 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() { CHECK(deckNormFromBipolar(3.0) == 1.0); } +static void testEveryDeckControlIsClassifiedLiveOrReloading() { + // The live set: the six filter tone/modulation knobs, plus every stage time and stage + // level on all three envelopes. + const DeckParam live[] = { + DeckParam::kFilterMorph, DeckParam::kFilterCutoff, DeckParam::kFilterQ, + DeckParam::kFilterDrive, DeckParam::kFilterModAmt, DeckParam::kFilterKeyTrack, + DeckParam::kAttack, DeckParam::kHold, DeckParam::kDecay, DeckParam::kSustain, + DeckParam::kRelease, + DeckParam::kFilterEnvAttack, DeckParam::kFilterEnvHold, DeckParam::kFilterEnvDecay, + DeckParam::kFilterEnvSustain, DeckParam::kFilterEnvRelease, + DeckParam::kPitchEnvAttack, DeckParam::kPitchEnvDecay, DeckParam::kPitchEnvDepth, + }; + for (DeckParam p : live) CHECK(isLiveDeckParam(p)); + + // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion + // is excluded. + const DeckParam reloads[] = { + DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, + DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel, + DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kTrigFadeIn, + DeckParam::kTrigFadeOut, DeckParam::kVoiceCount, DeckParam::kVoiceMode, + DeckParam::kMonoTrigger, DeckParam::kMasterGain, + }; + for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p)); + + // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check + // would stay green if an edit duplicated one id and dropped another, leaving that one + // unclassified. + for (int i = 0; i < static_cast(DeckParam::kCount); ++i) { + const DeckParam p = static_cast(i); + int seen = 0; + for (DeckParam q : live) if (q == p) ++seen; + for (DeckParam q : reloads) if (q == p) ++seen; + if (seen != 1) std::printf(" (deck id %d classified %d times)\n", i, seen); + CHECK(seen == 1); + } +} + +static void testOnlyALiveControlsDragTakesTheLiveTier() { + // isLiveDeckParam alone is not what a user experiences — liveCommitFor is, at the editor's + // commit site. Inverting it has to FAIL a test rather than merely read wrong. + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kFilterCutoff), + PlayMode::Gate)); + // A knob's routing is the knob's, not the play mode's. + CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kAttack), + PlayMode::Trigger)); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kTrigFadeIn), + PlayMode::Trigger)); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kMasterGain), + PlayMode::Gate)); + // The shell's processor-side sentinels (preview velocity is -2) and any out-of-range id + // are not parameter-set controls, so they must never reach the enum. + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -2, PlayMode::Gate)); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, -1, PlayMode::Gate)); + CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast(DeckParam::kCount), + PlayMode::Gate)); + // An envelope-node drag edits the AHDSR in Gate; the same drag in Trigger rewrites the + // play span, which is not a live control. + CHECK(liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Gate)); + CHECK(!liveCommitFor(LiveDragKind::kEnvNode, -1, PlayMode::Trigger)); + // Every other drag (markers, scrollbar, curve nodes) commits through a reload. + CHECK(!liveCommitFor(LiveDragKind::kOther, static_cast(DeckParam::kFilterCutoff), + PlayMode::Gate)); +} + int main() { + testEveryDeckControlIsClassifiedLiveOrReloading(); + testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); testFilterGroupCarriesItsFiveToneControlsPlusModulation(); testAmpGroupWidthSurvivesAGateTriggerFlip(); diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp new file mode 100644 index 0000000..2f5b30c --- /dev/null +++ b/tests/test_live_delivery.cpp @@ -0,0 +1,758 @@ +// Standalone tests for LIVE PARAMETER DELIVERY into a sounding voice — no VST3, no REAPER, no +// framework. The block's own publication contract is live_params_tests; this file asserts what +// reaches the audio: the mid-stage rule holds normalized position, a level move glides, a +// fresh note takes the newest block outright, every stage time and stage level on all three +// envelopes moves the note already sounding, a filter knob does too, two snapshots sharing one +// block behave identically (the drain slot), what stays latched at note-on stays latched, and +// an unmoved block renders byte-identically to the engine with no block at all. + +#include "../src/core/instrument/engine/voice_engine.h" + +#include +#include +#include +#include + +using namespace reasampler; +using instrument::engine::LiveParams; +using instrument::engine::LiveValues; +using instrument::engine::foldLive; +namespace flt = reasampler::instrument::engine::filter; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +constexpr double kPi = 3.14159265358979323846; +constexpr int kRate = 48000; + +static SampleData periodicSine(std::size_t frames, double period) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) { + s.frames[i] = static_cast(std::sin(2.0 * kPi * static_cast(i) / period)); + } + s.sampleRate = kRate; + s.rootNote = 60; + // Amp held wide open so a rendered frame is the (filtered) source, undisturbed by the + // envelope under test elsewhere in this file. + s.play.adsr.sustainLevel = 1.0; + return s; +} + +static double maxAbsDelta(const std::vector& v, std::size_t from, std::size_t to) { + double worst = 0.0; + for (std::size_t i = from + 1; i < to && i < v.size(); ++i) { + const double d = std::fabs(static_cast(v[i]) - static_cast(v[i - 1])); + if (d > worst) worst = d; + } + return worst; +} + +static double peakOf(const std::vector& v, std::size_t from, std::size_t to) { + double peak = 0.0; + for (std::size_t i = from; i < to && i < v.size(); ++i) { + peak = (std::max)(peak, std::fabs(static_cast(v[i]))); + } + return peak; +} + +static SampleData filteredSine() { + SampleData s = periodicSine(200000, 64.0); + s.play.filter.enabled = true; + s.play.filter.settings.cutoffNorm = 0.8f; + s.play.filter.settings.resonanceNorm = 0.9f; + s.play.filter.settings.morphNorm = 1.0f; + return s; +} + +// A low corner with real envelope depth, so the filter ENVELOPE's shape is what the timbre +// depends on rather than the static knob position. +static void filterSweep(SampleData& s) { + s.play.filter.enabled = true; + s.play.filter.settings.cutoffNorm = 0.15f; + s.play.filter.settings.resonanceNorm = 0.6f; + s.play.filter.settings.morphNorm = 1.0f; + s.play.filter.modAmount = 0.8; +} + +// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`, +// republishing `changed` at the top of block `changeAfter` and gating the note off at the top +// of `noteOffBlock` (-1 holds it). Voice-major render order is the engine's, so a fixed block +// size is what makes two runs comparable. +struct Run { + std::vector out; +}; + +// The note is an octave above the root on purpose: key-tracking scales (note - root), so a +// root-note test would leave the key-track control with nothing to move. +constexpr int kTestNote = 72; + +static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks, + int changeAfter, const LiveValues* changed, int noteOffBlock = -1, + int velocity = 100) { + sample.live = block; + if (block) block->publish(foldLive(sample.play)); + VoiceEngine engine(1, sample); + engine.noteOn(kTestNote, velocity); + Run r; + for (int b = 0; b < blocks; ++b) { + if (block && changed && b == changeAfter) block->publish(*changed); + if (b == noteOffBlock) engine.noteOff(kTestNote); + engine.render(r.out, static_cast(blockFrames)); + } + return r; +} + +// --- The mid-stage rule (candidate iv): hold normalized stage position ------------------ + +static void testStageDurationChangeHoldsPhase() { + AdsrParams p; + p.attackFrames = 1000; + p.sustainLevel = 1.0; + + AdsrEnvelope unedited, edited; + unedited.configure(p); + edited.configure(p); + unedited.noteOn(); + edited.noteOn(); + for (int i = 0; i < 500; ++i) { unedited.tick(); edited.tick(); } + + AdsrParams longer = p; + longer.attackFrames = 2000; // doubled while the voice sits halfway up the attack + edited.applyLive(longer); + + // Continuity: the very next frame is UNCHANGED by the edit. Exact, not approximate — + // phi is held, and the level is a pure function of phi. + const double a = unedited.tick(); + const double b = edited.tick(); + CHECK(a == b); + CHECK(std::fabs(b - 0.5) < 1e-12); // and it is genuinely mid-attack, not a degenerate 0/1 + + // The remainder takes its share of the NEW duration: half of 2000 frames left to run. + for (int i = 0; i < 998; ++i) edited.tick(); + CHECK(edited.stage() == AdsrEnvelope::Stage::Attack); + edited.tick(); + CHECK(edited.stage() != AdsrEnvelope::Stage::Attack); +} + +static void testShortenedStageStillLandsContinuously() { + AdsrParams p; + p.attackFrames = 1000; + p.sustainLevel = 1.0; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + double last = 0.0; + for (int i = 0; i < 800; ++i) last = env.tick(); + + AdsrParams shorter = p; + shorter.attackFrames = 100; // now SHORTER than the frames already elapsed + env.applyLive(shorter); + const double next = env.tick(); + // Recomputing from absolute elapsed (800/100) would clamp to 1.0 — a step from ~0.8. The + // phi rule keeps the level where it was and finishes the remaining 20% over 20 frames. + CHECK(std::fabs(next - last) < 2e-3); + for (int i = 0; i < 19; ++i) env.tick(); + CHECK(env.stage() != AdsrEnvelope::Stage::Attack); +} + +static void testSustainLevelChangeGlides() { + AdsrParams p; + p.sustainLevel = 1.0; + p.releaseFrames = 100000; + AdsrEnvelope env; + env.configure(p); + env.noteOn(); + for (int i = 0; i < 50; ++i) env.tick(); + CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); + + AdsrParams quieter = p; + quieter.sustainLevel = 0.2; + env.applyLive(quieter); + + double prev = 1.0; + double worstStep = 0.0; + double v = 0.0; + for (int i = 0; i < 600; ++i) { + v = env.tick(); + // The first frame reproduces the pre-change level. Bounded rather than compared + // exactly: 0.2 + fl(1.0 - 0.2) does round to exactly 1.0 for THESE operands, but the + // property under test is continuity, not a bit-exactness the smoother never promised. + if (i == 0) CHECK(std::fabs(v - 1.0) < 1e-15); + const double step = std::fabs(v - prev); + if (step > worstStep) worstStep = step; + prev = v; + } + // A raw parameter swap would step 0.8 in one frame; the glide's largest single step is a + // small fraction of that, and it terminates exactly on the new level. + CHECK(worstStep < 0.05); + CHECK(v == 0.2); +} + +static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() { + PitchEnvParams p; + p.enabled = true; + p.attackFrames = 0; + p.decayFrames = 1000; + p.peakSemitones = 12.0; + PitchEnvelope a, b; + a.configure(p); + b.configure(p); + a.noteOn(); + b.noteOn(); + for (int i = 0; i < 400; ++i) { a.tick(); b.tick(); } + + b.applyLive(0, 2000, 12.0); // decay doubled mid-decay + CHECK(a.tick() == b.tick()); // phi held: the semitone offset is unchanged this frame + + // A depth move is a level step, so it glides rather than jumping: the first frame after + // the edit is exactly what the unedited peer emits. + PitchEnvelope c, d; + c.configure(p); + d.configure(p); + c.noteOn(); + d.noteOn(); + for (int i = 0; i < 400; ++i) { c.tick(); d.tick(); } + c.applyLive(0, 1000, 0.0); // depth to zero mid-decay + CHECK(c.tick() == d.tick()); + // ...and it does eventually reach the new depth rather than staying put. + for (int i = 0; i < 400; ++i) c.tick(); + CHECK(c.tick() == 0.0); +} + +// --- The fresh-note path: snap, never the phi rule --------------------------------------- + +static void testAFreshEnvelopeTakesANewlyDialledStageTimeOutright() { + // Regression, both directions. The snap path once ran applyLive's phi rule, which reads a + // stale duration of 0 as "this stage is already complete" and threw the newly-dialled + // attack away for every note until the next reload. + AdsrParams stale; // the AdsrParams default: every stage zero + stale.sustainLevel = 1.0; + AdsrEnvelope env; + env.configure(stale); + env.noteOn(); + AdsrParams dialled = stale; + dialled.attackFrames = 100; + env.snapLive(dialled); + CHECK(env.tick() == 0.0); // frame 0 of a 100-frame attack, not an instant 1.0 + for (int i = 0; i < 49; ++i) env.tick(); + CHECK(std::fabs(env.tick() - 0.5) < 1e-12); + + // Reverse: a stale non-zero attack against a newly-dialled ZERO one must not absorb a + // full-scale step into a voice that has emitted nothing — that fades in a note the user + // asked to be instant. + AdsrParams staleLong; + staleLong.attackFrames = 1000; + staleLong.sustainLevel = 1.0; + AdsrEnvelope instant; + instant.configure(staleLong); + instant.noteOn(); + AdsrParams zeroAttack = staleLong; + zeroAttack.attackFrames = 0; + instant.snapLive(zeroAttack); + CHECK(instant.tick() == 1.0); +} + +static void testAFreshPitchEnvelopeTakesTheNewTimesOutright() { + PitchEnvParams stale; // enabled, but every leg zero + stale.enabled = true; + PitchEnvelope env; + env.configure(stale); + env.noteOn(); + env.snapLive(0, 1000, 12.0); + CHECK(env.tick() == 12.0); // at the top of the new decay leg, not past the envelope + for (int i = 0; i < 499; ++i) env.tick(); + CHECK(std::fabs(env.tick() - 6.0) < 1e-12); +} + +static void testANoteStartedAfterAPublishSoundsThePublishedEnvelope() { + // End-to-end shape of the snap path: a live commit deliberately leaves the snapshot's own + // sample.play stale, so the ONLY thing standing between a new note and a stale envelope is + // the snap. This is the coverage whose absence let the phi-on-snap bug through. + SampleData s = periodicSine(200000, 64.0); // adsr default: attack 0, sustain 1.0 + LiveParams block; + s.live = █ + LiveValues dialled = foldLive(s.play); + dialled.adsr.attackFrames = 24000; // half a second of attack, dialled before the note + block.publish(dialled); + VoiceEngine engine(1, s); + engine.noteOn(kTestNote, 100); + std::vector out; + engine.render(out, 512); + + // Control: the same stale snapshot with no block at all speaks at full level immediately. + SampleData bare = periodicSine(200000, 64.0); + VoiceEngine bareEngine(1, bare); + bareEngine.noteOn(kTestNote, 100); + std::vector bareOut; + bareEngine.render(bareOut, 512); + const double barePeak = peakOf(bareOut, 0, bareOut.size()); + const double peak = peakOf(out, 0, out.size()); + CHECK(barePeak > 0.9); + CHECK(peak < barePeak * 0.1); // 512 frames into a 24000-frame attack: ~2% of full scale + + // Reverse: a stale LONG attack against a published zero one. The note must speak at full + // level within its first cycle rather than fading in over the smoother's decay. + SampleData slow = periodicSine(200000, 64.0); + slow.play.adsr.attackFrames = 24000; + LiveParams block2; + slow.live = &block2; + LiveValues snappy = foldLive(slow.play); + snappy.adsr.attackFrames = 0; + block2.publish(snappy); + VoiceEngine fast(1, slow); + fast.noteOn(kTestNote, 100); + std::vector fastOut; + fast.render(fastOut, 512); + // Source period 64 read at ratio 2 peaks at output frame 8; a spurious smoother fade-in + // would still be at ~0.34 there. + CHECK(peakOf(fastOut, 0, 32) > 0.9); +} + +// --- Every envelope stage, end to end through the engine --------------------------------- + +// Renders the same note twice — once untouched, once with `mutate` published mid-note — and +// asserts the field reached the SOUNDING voice (the tail diverges) and only after its publish. +static void assertLiveFieldMovesTheSoundingNote(const char* name, void (*rig)(SampleData&), + void (*mutate)(LiveValues&), int noteOffBlock) { + SampleData still = periodicSine(200000, 64.0); + SampleData moved = periodicSine(200000, 64.0); + rig(still); + rig(moved); + LiveParams blockA, blockB; + LiveValues target = foldLive(moved.play); + mutate(target); + + const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr, noteOffBlock); + const Run edited = renderWithLive(moved, &blockB, 512, 24, 8, &target, noteOffBlock); + + CHECK(baseline.out.size() == edited.out.size()); + double tailDiff = 0.0; + for (std::size_t i = 512 * 9; i < baseline.out.size() && i < edited.out.size(); ++i) { + tailDiff += std::fabs(static_cast(edited.out[i]) - + static_cast(baseline.out[i])); + } + if (!(tailDiff > 1.0)) std::printf(" (never reached the voice: %s)\n", name); + CHECK(tailDiff > 1.0); + + bool preChangeIdentical = true; + for (std::size_t i = 0; i < 512 * 8 && i < baseline.out.size(); ++i) { + if (edited.out[i] != baseline.out[i]) { preChangeIdentical = false; break; } + } + if (!preChangeIdentical) std::printf(" (moved before its publish: %s)\n", name); + CHECK(preChangeIdentical); +} + +static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() { + // Each rig puts the voice INSIDE the stage under test at the publish (block 8, output + // frame 4096) — a stage already passed cannot move, which is the physics, not a gap. + struct Case { + const char* name; + void (*rig)(SampleData&); + void (*mutate)(LiveValues&); + int noteOffBlock; + }; + const Case cases[] = { + {"amp attack", + [](SampleData& s) { s.play.adsr.attackFrames = 48000; }, + [](LiveValues& v) { v.adsr.attackFrames = 4000; }, -1}, + {"amp hold", + [](SampleData& s) { + s.play.adsr.holdFrames = 48000; + s.play.adsr.decayFrames = 4000; + s.play.adsr.sustainLevel = 0.1; + }, + [](LiveValues& v) { v.adsr.holdFrames = 5000; }, -1}, + {"amp decay", + [](SampleData& s) { + s.play.adsr.decayFrames = 48000; + s.play.adsr.sustainLevel = 0.0; + }, + [](LiveValues& v) { v.adsr.decayFrames = 8000; }, -1}, + {"amp sustain", + [](SampleData& s) { s.play.adsr.sustainLevel = 1.0; }, + [](LiveValues& v) { v.adsr.sustainLevel = 0.2; }, -1}, + {"amp release", + [](SampleData& s) { s.play.adsr.releaseFrames = 48000; }, + [](LiveValues& v) { v.adsr.releaseFrames = 6000; }, 2}, + + // The filter envelope: swept over a low corner with real depth, so its shape is the + // only thing the timbre depends on. The amp release is long so a gated-off voice + // keeps sounding while the filter release is measured. + {"filter env attack", + [](SampleData& s) { filterSweep(s); s.play.filter.env.attackFrames = 48000; }, + [](LiveValues& v) { v.filterEnv.attackFrames = 4000; }, -1}, + {"filter env hold", + [](SampleData& s) { + filterSweep(s); + s.play.filter.env.holdFrames = 48000; + s.play.filter.env.decayFrames = 4000; + s.play.filter.env.sustainLevel = 0.0; + }, + [](LiveValues& v) { v.filterEnv.holdFrames = 5000; }, -1}, + {"filter env decay", + [](SampleData& s) { + filterSweep(s); + s.play.filter.env.decayFrames = 48000; + s.play.filter.env.sustainLevel = 0.0; + }, + [](LiveValues& v) { v.filterEnv.decayFrames = 8000; }, -1}, + {"filter env sustain", + [](SampleData& s) { filterSweep(s); }, + [](LiveValues& v) { v.filterEnv.sustainLevel = 0.0; }, -1}, + {"filter env release", + [](SampleData& s) { + filterSweep(s); + s.play.filter.env.releaseFrames = 48000; + s.play.adsr.releaseFrames = 480000; + }, + [](LiveValues& v) { v.filterEnv.releaseFrames = 6000; }, 2}, + + {"pitch env attack", + [](SampleData& s) { + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.attackFrames = 48000; + s.play.pitchEnv.decayFrames = 48000; + s.play.pitchEnv.peakSemitones = 12.0; + }, + [](LiveValues& v) { v.pitchEnvAttackFrames = 4000; }, -1}, + {"pitch env decay", + [](SampleData& s) { + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.decayFrames = 48000; + s.play.pitchEnv.peakSemitones = 12.0; + }, + [](LiveValues& v) { v.pitchEnvDecayFrames = 8000; }, -1}, + {"pitch env depth", + [](SampleData& s) { + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.decayFrames = 480000; + s.play.pitchEnv.peakSemitones = 12.0; + }, + [](LiveValues& v) { v.pitchEnvPeakSemitones = 0.0; }, -1}, + }; + for (const Case& c : cases) { + assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock); + } +} + +// --- The filter DSP's glide property, finally exercised --------------------------------- + +static void testCutoffMoveAcrossPrepareDoesNotStep() { + flt::FilterSettings s; + s.cutoffNorm = 0.8f; + s.resonanceNorm = 1.0f; // maximum Q: the worst case for a coefficient step + s.morphNorm = 1.0f; + + flt::VoiceFilter glide, cut; + glide.prepare(s, kRate); + cut.prepare(s, kRate); + + std::vector a, b; + const int boundary = 2000; + for (int i = 0; i < 4000; ++i) { + if (i == boundary) { + flt::FilterSettings moved = s; + moved.cutoffNorm = 0.3f; + glide.prepare(moved, kRate); // state PRESERVED — the documented glide property + cut.prepare(moved, kRate); + cut.reset(); // the control: state cleared, as at note-on + } + const float x = static_cast(std::sin(2.0 * kPi * static_cast(i) / 48.0)); + a.push_back(glide.process(0, x)); + b.push_back(cut.process(0, x)); + } + + const double localMax = maxAbsDelta(a, boundary - 400, boundary - 1); + const double glideStep = std::fabs(static_cast(a[boundary]) - + static_cast(a[boundary - 1])); + const double cutStep = std::fabs(static_cast(b[boundary]) - + static_cast(b[boundary - 1])); + // Preserving state keeps the boundary frame inside the signal's own frame-to-frame range; + // clearing it does not — which is what proves this assertion discriminates rather than + // passing on any pair of numbers. + CHECK(glideStep <= localMax); + CHECK(cutStep > glideStep * 4.0); +} + +// --- Delivery into a sounding voice ------------------------------------------------------ + +static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() { + SampleData bare = filteredSine(); + SampleData blocked = filteredSine(); + LiveParams block; + const Run without = renderWithLive(bare, nullptr, 512, 20, -1, nullptr); + const Run with = renderWithLive(blocked, &block, 512, 20, -1, nullptr); + CHECK(without.out.size() == with.out.size()); + bool identical = true; + for (std::size_t i = 0; i < without.out.size() && i < with.out.size(); ++i) { + if (without.out[i] != with.out[i]) { identical = false; break; } + } + // Also the migration bar: a blob saved before this change folds to exactly the values the + // build already resolved, so reopening it sounds identical rather than merely close. + CHECK(identical); +} + +static void testEveryLiveFilterControlMovesTheSoundingNote() { + struct Case { const char* name; void (*mutate)(LiveValues&); }; + const Case cases[] = { + {"cutoff", [](LiveValues& v) { v.filterSettings.cutoffNorm = 0.15f; }}, + {"Q", [](LiveValues& v) { v.filterSettings.resonanceNorm = 0.1f; }}, + {"morph", [](LiveValues& v) { v.filterSettings.morphNorm = 0.0f; }}, + {"drive", [](LiveValues& v) { v.filterSettings.driveNorm = 1.0f; }}, + {"mod", [](LiveValues& v) { v.filterModAmount = 1.0; }}, + {"keytrack", [](LiveValues& v) { v.filterKeyTrack = 2.0; }}, + }; + + for (const Case& c : cases) { + SampleData still = filteredSine(); + SampleData moved = filteredSine(); + LiveParams blockA, blockB; + LiveValues target = foldLive(moved.play); + c.mutate(target); + + const Run baseline = renderWithLive(still, &blockA, 512, 24, -1, nullptr); + const Run swept = renderWithLive(moved, &blockB, 512, 24, 8, &target); + + // It moved THIS note: the tail after the publish differs audibly from the untouched + // render of the same note. + double tailDiff = 0.0; + for (std::size_t i = 512 * 12; i < baseline.out.size(); ++i) { + tailDiff += std::fabs(static_cast(swept.out[i]) - + static_cast(baseline.out[i])); + } + if (!(tailDiff > 1.0)) std::printf(" (control: %s)\n", c.name); + CHECK(tailDiff > 1.0); + + // Nothing before the publish moved (the block is observed at block boundaries only). + bool preChangeIdentical = true; + for (std::size_t i = 0; i < 512 * 8; ++i) { + if (swept.out[i] != baseline.out[i]) { preChangeIdentical = false; break; } + } + CHECK(preChangeIdentical); + + // And it ARRIVED as a glide, not as a step. Measured as how far the swept render has + // departed from the untouched one in the first frames after the publish, against how + // far it departs once settled: a glide has barely begun to diverge, a snapped delivery + // is already all the way there. + // + // This is the assertion that discriminates. A single-frame-spike metric does NOT: the + // TPT filter preserves state across prepare(), so even an instantaneous coefficient + // jump produces no isolated output spike — measured, by defeating the ramp and + // re-running, the spike statistic was unchanged while these two numbers converged. + // + // The window is a FRACTION OF THE GLIDE, not a frame count: kLiveRampSeconds is the + // full travel time, so at 1/240 of it a working glide has barely started when the + // window closes. kGlideMargin then puts the bound at the geometric middle of the two + // MEASURED populations — with the ramp in place these six controls ratio 0.0005..0.060; + // with it defeated (every live move delivered as a snap, run) they ratio 0.52..1.10. + // The bound lands at 0.175: ~3x above the worst glide, ~3x below the tamest snap. + const std::size_t rampFrames = + static_cast(instrument::engine::kLiveRampSeconds * kRate); + const std::size_t window = rampFrames / 240; + const double kGlideMargin = 42.0; + const double bound = kGlideMargin * static_cast(window) / + static_cast(rampFrames); + double immediate = 0.0; + for (std::size_t i = 512 * 8; i < 512 * 8 + window; ++i) { + immediate = (std::max)(immediate, std::fabs(static_cast(swept.out[i]) - + static_cast(baseline.out[i]))); + } + double settled = 0.0; + for (std::size_t i = 512 * 14; i < baseline.out.size(); ++i) { + settled = (std::max)(settled, std::fabs(static_cast(swept.out[i]) - + static_cast(baseline.out[i]))); + } + if (!(immediate <= settled * bound)) + std::printf(" (glide: %s ratio %f vs bound %f)\n", c.name, + settled > 0.0 ? immediate / settled : -1.0, bound); + CHECK(immediate <= settled * bound); + } +} + +static void testOneBlockServesTwoIndependentObservers() { + // Two snapshots, one block — exactly the processor's live_/draining_ shape. The claim is + // narrow and specific: read() does NOT consume the generation, so the second engine to + // observe a publish sees it as fully as the first. Two identically-built engines are + // otherwise identical by construction, so that is the only thing the comparison pins. + SampleData liveSnapshot = filteredSine(); + SampleData drainSnapshot = filteredSine(); + LiveParams block; + liveSnapshot.live = █ + drainSnapshot.live = █ + block.publish(foldLive(liveSnapshot.play)); + + VoiceEngine liveEngine(1, liveSnapshot); + VoiceEngine drainEngine(1, drainSnapshot); + liveEngine.noteOn(60, 100); + drainEngine.noteOn(60, 100); + + std::vector a, b; + LiveValues moved = foldLive(liveSnapshot.play); + moved.filterSettings.cutoffNorm = 0.2f; + for (int blk = 0; blk < 24; ++blk) { + if (blk == 8) block.publish(moved); + liveEngine.render(a, 512); + drainEngine.render(b, 512); + } + CHECK(a.size() == b.size()); + bool same = true; + for (std::size_t i = 0; i < a.size() && i < b.size(); ++i) { + if (a[i] != b[i]) { same = false; break; } + } + CHECK(same); + // The shared block genuinely moved the sound, so "identical" is a claim about both + // observers having seen it rather than about nothing having happened. + double moveEnergy = 0.0; + for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]); + CHECK(moveEnergy > 1.0); + // And a THIRD observer, after both engines have read it, still sees the same publish. + LiveValues seen; + CHECK(block.read(seen) != 0); + CHECK(seen.filterSettings.cutoffNorm == 0.2f); +} + +// --- What stays latched at note-on ------------------------------------------------------- + +static void testPitchRatioAndVelocityGainStayLatched() { + // A ramp source read under Varispeed: every output frame is (source at readPos) * velocity + // gain, so a moved pitch ratio or a moved velocity gain would show up directly. + // + // The filter and the pitch envelope are OFF here on purpose — that is what makes the read + // rate provable arithmetic. It also means the block's filter and pitch-envelope fields + // cannot land on this voice; that they DO land on a voice that has them enabled, and still + // leave the velocity gain alone, is the next test's job. + SampleData s; + s.frames.resize(100000); + for (std::size_t i = 0; i < s.frames.size(); ++i) { + s.frames[i] = static_cast(static_cast(i) / 100000.0); + } + s.sampleRate = kRate; + s.rootNote = 60; + s.velocityCurve = VelocityCurve::linear(); + s.play.adsr.sustainLevel = 1.0; + + LiveParams block; + s.live = █ + block.publish(foldLive(s.play)); + VoiceEngine engine(1, s); + engine.noteOn(72, 64); // an octave up: ratio 2.0 + + std::vector out; + LiveValues hostile = foldLive(s.play); + // Everything the block CAN carry, moved as far as it goes. None of it names velocity, the + // note, the pitch ratio, or the PCM — that is the property under test. + hostile.filterKeyTrack = 2.0; + hostile.filterSettings.cutoffNorm = 0.0f; + hostile.filterModAmount = 1.0; + hostile.pitchEnvAttackFrames = 4800; + hostile.pitchEnvDecayFrames = 4800; + hostile.pitchEnvPeakSemitones = 24.0; + hostile.adsr.attackFrames = 96000; // a timed stage the voice is already past + for (int blk = 0; blk < 8; ++blk) { + if (blk == 2) block.publish(hostile); + engine.render(out, 512); + } + + const double velocityGain = s.velocityCurve.eval(64.0); + bool pitchAndGainHeld = true; + for (std::size_t i = 0; i < out.size(); ++i) { + const double expected = + (static_cast(2 * i) / 100000.0) * velocityGain; // ratio 2.0, latched gain + if (std::fabs(static_cast(out[i]) - expected) > 1e-6) { + pitchAndGainHeld = false; + break; + } + } + CHECK(pitchAndGainHeld); + + // Positive control on the same rig: a field that IS live does change the output, so the + // assertion above is not simply proving the block was ignored wholesale. + SampleData s2 = s; + LiveParams block2; + s2.live = &block2; + block2.publish(foldLive(s2.play)); + VoiceEngine engine2(1, s2); + engine2.noteOn(72, 64); + std::vector out2; + LiveValues quieter = foldLive(s2.play); + quieter.adsr.sustainLevel = 0.25; + for (int blk = 0; blk < 8; ++blk) { + if (blk == 2) block2.publish(quieter); + engine2.render(out2, 512); + } + CHECK(std::fabs(static_cast(out2.back()) - static_cast(out.back())) > 1e-4); +} + +static void testVelocityGainSurvivesAHostilePublishThatReallyLands() { + // Filter AND pitch envelope enabled, so every field the block carries actually reaches the + // voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain + // latched at note-on — which makes two runs at different velocities exactly proportional + // unless the publish moved that gain (a re-derived gain would have to preserve the ratio + // 100:64 to slip through). + SampleData rig = periodicSine(200000, 64.0); + rig.velocityCurve = VelocityCurve::linear(); + filterSweep(rig); + rig.play.filter.velAmount = 0.0; + rig.play.pitchEnv.enabled = true; + rig.play.pitchEnv.decayFrames = 24000; + rig.play.pitchEnv.peakSemitones = 3.0; + + LiveValues hostile = foldLive(rig.play); + hostile.filterKeyTrack = 2.0; + hostile.filterSettings.cutoffNorm = 0.9f; + hostile.filterModAmount = -1.0; + hostile.filterEnv.decayFrames = 4800; + hostile.filterEnv.sustainLevel = 0.0; + hostile.pitchEnvAttackFrames = 4800; + hostile.pitchEnvDecayFrames = 4800; + hostile.pitchEnvPeakSemitones = 24.0; + hostile.adsr.sustainLevel = 0.4; + + SampleData quiet = rig, loud = rig, untouched = rig; + LiveParams blockQuiet, blockLoud, blockUntouched; + const Run atQuiet = renderWithLive(quiet, &blockQuiet, 512, 16, 2, &hostile, -1, 64); + const Run atLoud = renderWithLive(loud, &blockLoud, 512, 16, 2, &hostile, -1, 100); + const Run noPublish = renderWithLive(untouched, &blockUntouched, 512, 16, -1, nullptr, -1, 64); + + // The publish is not inert: it moved the note it was published into. + double landed = 0.0; + for (std::size_t i = 512 * 3; i < atQuiet.out.size() && i < noPublish.out.size(); ++i) { + landed += std::fabs(static_cast(atQuiet.out[i]) - + static_cast(noPublish.out[i])); + } + CHECK(landed > 1.0); + + // ...and through all of it the two velocities differ by exactly the curve's ratio. + const double ratio = rig.velocityCurve.eval(100.0) / rig.velocityCurve.eval(64.0); + CHECK(ratio > 1.5); // the curve really does separate these two velocities + bool proportional = true; + for (std::size_t i = 0; i < atQuiet.out.size() && i < atLoud.out.size(); ++i) { + if (std::fabs(static_cast(atLoud.out[i]) - + static_cast(atQuiet.out[i]) * ratio) > 1e-6) { + proportional = false; + break; + } + } + CHECK(proportional); +} + +int main() { + testStageDurationChangeHoldsPhase(); + testShortenedStageStillLandsContinuously(); + testSustainLevelChangeGlides(); + testPitchEnvelopeHoldsPhaseAndGlidesDepth(); + testAFreshEnvelopeTakesANewlyDialledStageTimeOutright(); + testAFreshPitchEnvelopeTakesTheNewTimesOutright(); + testCutoffMoveAcrossPrepareDoesNotStep(); + testUnmovedBlockIsByteIdenticalToNoBlockAtAll(); + testANoteStartedAfterAPublishSoundsThePublishedEnvelope(); + testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote(); + testEveryLiveFilterControlMovesTheSoundingNote(); + testOneBlockServesTwoIndependentObservers(); + testPitchRatioAndVelocityGainStayLatched(); + testVelocityGainSurvivesAHostilePublishThatReallyLands(); + if (g_fail == 0) std::printf("live_delivery tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_live_params.cpp b/tests/test_live_params.cpp new file mode 100644 index 0000000..71095b3 --- /dev/null +++ b/tests/test_live_params.cpp @@ -0,0 +1,165 @@ +// Standalone tests for the live-parameter block itself — no VST3, no REAPER, no framework: +// the single fold from the parameter set, the seqlock's coherence under a concurrent writer, +// and the ramp's exact termination. The block's effect on a sounding voice is +// live_delivery_tests. + +#include "../src/core/instrument/engine/live_params.h" + +#include +#include +#include +#include + +using namespace reasampler; +using instrument::engine::LiveParams; +using instrument::engine::LiveValues; +using instrument::engine::ValueRamp; +using instrument::engine::foldLive; +using instrument::engine::liveRampStep; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// The block is copied wholesale under the seqlock, so anything that owns memory here would be +// a use-after-free waiting for a racing publish. +static_assert(std::is_trivially_copyable_v, "the live block must stay plain data"); + +static void testFoldCarriesEveryContinuousControl() { + PlayParams p; + p.adsr = AdsrParams{11, 22, 33, 0.44, 55}; + p.filter.enabled = true; + p.filter.settings.cutoffNorm = 0.25f; + p.filter.settings.resonanceNorm = 0.5f; + p.filter.settings.morphNorm = 0.75f; + p.filter.settings.driveNorm = 0.125f; + p.filter.modAmount = -0.6; + p.filter.keyTrack = 1.5; + p.filter.env = AdsrParams{1, 2, 3, 0.4, 5}; + p.pitchEnv.attackFrames = 7; + p.pitchEnv.decayFrames = 9; + p.pitchEnv.peakSemitones = -3.5; + + const LiveValues v = foldLive(p); + CHECK(v.adsr.attackFrames == 11); + CHECK(v.adsr.holdFrames == 22); + CHECK(v.adsr.decayFrames == 33); + CHECK(v.adsr.sustainLevel == 0.44); + CHECK(v.adsr.releaseFrames == 55); + CHECK(v.filterSettings.cutoffNorm == 0.25f); + CHECK(v.filterSettings.resonanceNorm == 0.5f); + CHECK(v.filterSettings.morphNorm == 0.75f); + CHECK(v.filterSettings.driveNorm == 0.125f); + CHECK(v.filterModAmount == -0.6); + CHECK(v.filterKeyTrack == 1.5); + CHECK(v.filterEnv.decayFrames == 3); + CHECK(v.filterEnv.sustainLevel == 0.4); + CHECK(v.pitchEnvAttackFrames == 7); + CHECK(v.pitchEnvDecayFrames == 9); + CHECK(v.pitchEnvPeakSemitones == -3.5); +} + +static void testUnpublishedBlockReadsAsNothing() { + LiveParams block; + LiveValues out; + CHECK(block.read(out) == 0); // never published: the caller must keep its own defaults + + LiveValues v; + v.filterModAmount = 0.5; + block.publish(v); + const std::uint32_t first = block.read(out); + CHECK(first != 0); + CHECK(out.filterModAmount == 0.5); + // An unchanged block reports the SAME generation, which is how the reader knows there is + // nothing to push into the voices. + CHECK(block.read(out) == first); + block.publish(v); + CHECK(block.read(out) != first); +} + +// The torn-read hazard, exercised rather than argued: a writer publishes multi-field edits as +// fast as it can while a reader copies the block; every observed block must be one the writer +// actually published, never half of one and half of another. +static void testConcurrentReaderNeverSeesAHalfAppliedEdit() { + LiveParams block; + std::atomic stop{false}; + std::atomic observed{0}; + std::atomic torn{0}; + + LiveValues seed; + seed.adsr = AdsrParams{0, 0, 0, 0.0, 0}; + block.publish(seed); + + std::thread writer([&] { + for (std::int64_t i = 1; !stop.load(std::memory_order_relaxed); ++i) { + LiveValues v; + // One coherent edit: every AHDSR field derives from the same i, so any mixture of + // two edits is detectable from the values alone. + v.adsr = AdsrParams{i, 2 * i, 3 * i, static_cast(i), 5 * i}; + v.filterEnv = AdsrParams{4 * i, 5 * i, 6 * i, static_cast(i), 7 * i}; + v.filterModAmount = static_cast(i); + block.publish(v); + } + }); + + for (int n = 0; n < 200000; ++n) { + LiveValues out; + if (block.read(out) == 0) continue; // abandoned read: the caller discards it + observed.fetch_add(1, std::memory_order_relaxed); + const std::int64_t i = out.adsr.attackFrames; + const bool coherent = + out.adsr.holdFrames == 2 * i && out.adsr.decayFrames == 3 * i && + out.adsr.releaseFrames == 5 * i && out.adsr.sustainLevel == static_cast(i) && + out.filterEnv.attackFrames == 4 * i && out.filterEnv.holdFrames == 5 * i && + out.filterEnv.decayFrames == 6 * i && out.filterEnv.releaseFrames == 7 * i && + out.filterModAmount == static_cast(i); + if (!coherent) torn.fetch_add(1, std::memory_order_relaxed); + } + stop.store(true, std::memory_order_relaxed); + writer.join(); + + CHECK(torn.load() == 0); + CHECK(observed.load() > 0); // the run proved something only if reads actually landed +} + +static void testRampTerminatesExactlyOnTheTarget() { + ValueRamp r; + r.set(0.0); + r.step = 1.0 / 960.0; // the 20 ms step at 48 kHz + r.aim(0.4); + int frames = 0; + while (r.moving() && frames < 100000) { r.tick(); ++frames; } + // Exact equality, not a tolerance: the filter's cutoff-skip fast path compares the value + // itself, so an asymptotic smoother would pin it on the always-re-solve path forever. + CHECK(r.value == 0.4); + CHECK(!r.moving()); + CHECK(!r.tick()); // parked: no further movement reported + CHECK(frames > 1); // it glided rather than jumping +} + +static void testRampWithNoRateSnaps() { + ValueRamp r; + r.set(0.2); + r.step = liveRampStep(0.0); // rate unknown: never invent one + CHECK(r.step == 0.0); + r.aim(0.9); + CHECK(r.tick()); + CHECK(r.value == 0.9); +} + +static void testRampStepIsRateDerived() { + CHECK(liveRampStep(48000.0) == 1.0 / (0.020 * 48000.0)); + CHECK(liveRampStep(96000.0) == 1.0 / (0.020 * 96000.0)); + CHECK(liveRampStep(-1.0) == 0.0); +} + +int main() { + testFoldCarriesEveryContinuousControl(); + testUnpublishedBlockReadsAsNothing(); + testConcurrentReaderNeverSeesAHalfAppliedEdit(); + testRampTerminatesExactlyOnTheTarget(); + testRampWithNoRateSnaps(); + testRampStepIsRateDerived(); + if (g_fail == 0) std::printf("live_params tests passed\n"); + return g_fail == 0 ? 0 : 1; +}