instrument: snap live params onto a fresh voice, roll a live drag back on capture loss, serialize the seqlock's two writers

This commit is contained in:
2026-07-30 21:39:56 -04:00
parent 1dade0bfcf
commit bbc7dc70bb
15 changed files with 621 additions and 143 deletions
+26 -20
View File
@@ -160,23 +160,28 @@ Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other
must live compute, latching the parameters at note on is not acceptable. long term these will be 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. automatable parameters."* It rejects the precedent, not one instance of it.
- **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` - **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and
(`ui/deck_groups`). Continuous playback controls go live: the six filter tone/modulation `liveCommitFor` (`ui/deck_groups`), whose header is the home for why each excluded control is
knobs, and every stage time and stage level on all three envelopes. Everything else reloads excluded. Continuous playback controls go live: the six filter tone/modulation knobs, and
or drops to the voice-param rebuild. Two controls look continuous but are deliberately not every stage time and stage level on all three envelopes. Everything else reloads or drops to
live — the pitch key-track and the velocity→cutoff depth feed values a voice latches at the voice-param rebuild. **FIVE continuous controls are outside the live set** — pitch
note-on (the pitch ratio, the velocity-curve result), so making them live would retune or key-track, velocitycutoff depth, and Trigger's %-length, fade-in and fade-out — so a
re-gain a note already struck. The three capture-anchored overrides (root, loop span, start Trigger-mode instance gets no live delivery on its amplitude controls at all; only the filter
frame) reload because they name positions in the decoded PCM. and pitch-envelope knobs move a sounding one-shot.
- **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one - **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one
block the shell owns per instance; a block owned by a snapshot would leave the drain slot's block the shell owns per instance. The member-ordering constraint that enforces it, and why,
still-sounding voices deaf to the knob under them. A drain voice tracking the knob is the are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice
DESIRED behaviour — it is the note the user is hearing. 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 - **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. is why `sampler_core`'s regression baselines needed no change.
- **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once - **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 one predicted branch per `render()` and once per note-on; the per-sample path gained three predicted branches (the
(`filterRamping_`) and no indirection. 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): - **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 φ = 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 construction and the remainder takes its share of the new duration. Stated over normalized
@@ -232,7 +237,7 @@ 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: - 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. - `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. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule) and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover. - `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. - `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.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 (132, 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. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, 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.
@@ -261,7 +266,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. - `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. - `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. - `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` — also home to `isLiveDeckParam`, the editor's commit-tier routing predicate (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. - `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. - `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_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. - `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.
@@ -280,13 +285,14 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
- **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact - **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 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 mid-note starts the envelope from the note's stage-0 position rather than from where it would
have been. Continuous either way (the contribution starts at 0), and keeping the skip is what have been. Its step smoother is frozen with it — an absorbed step sits in the offset and
holds the at-rest per-sample path byte-identical — but don't read a live depth move as emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0).
"resuming" an envelope that was never running. 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 - **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 snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
`applyLive(snap)` before its first frame. The persisted `params_` is written by the same `snapLive` before its first frame.
commit, so `getState` is never stale.
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep - **`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); covers client-pixel widths (including multiples standing in for larger client areas);
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
+35 -19
View File
@@ -90,6 +90,17 @@ public:
stagePos_ = 0.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 // 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 // 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 // level is unchanged by construction and the remainder of the stage takes its share of the
@@ -117,6 +128,11 @@ public:
// Once Release completes the envelope latches Finished and returns 0.0 forever (until // 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 // the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
// output frame. // 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() { double tick() {
const double out = tickStage(); const double out = tickStage();
return smooth_.active() ? out + smooth_.advance() : out; return smooth_.active() ? out + smooth_.advance() : out;
@@ -127,8 +143,11 @@ public:
double level() const { return level_; } double level() const { return level_; }
private: private:
// The level tick() would emit right now under `params`, without advancing anything — the // The level tick() would emit right now under `params` without advancing anything. THE one
// prediction applyLive compares across the change to size the smoother. // 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 { double stageLevel(const AdsrParams& params) const {
switch (stage_) { switch (stage_) {
case Stage::Attack: { case Stage::Attack: {
@@ -178,12 +197,7 @@ private:
return 0.0; return 0.0;
case Stage::Attack: { case Stage::Attack: {
if (params_.attackFrames <= 0) { level_ = stageLevel(params_);
level_ = 1.0;
} else {
level_ = stagePos_ / static_cast<double>(params_.attackFrames);
if (level_ > 1.0) level_ = 1.0;
}
const double out = level_; const double out = level_;
stagePos_ += 1.0; stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.attackFrames)) { if (stagePos_ >= static_cast<double>(params_.attackFrames)) {
@@ -208,7 +222,7 @@ private:
// smoother is applied exactly once per frame. // smoother is applied exactly once per frame.
return tickStage(); return tickStage();
} }
level_ = 1.0; level_ = stageLevel(params_);
const double out = level_; const double out = level_;
stagePos_ += 1.0; stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.holdFrames)) { if (stagePos_ >= static_cast<double>(params_.holdFrames)) {
@@ -220,12 +234,7 @@ private:
} }
case Stage::Decay: { case Stage::Decay: {
if (params_.decayFrames <= 0) { level_ = stageLevel(params_);
level_ = params_.sustainLevel;
} else {
const double t = stagePos_ / static_cast<double>(params_.decayFrames);
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
}
const double out = level_; const double out = level_;
stagePos_ += 1.0; stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.decayFrames)) { if (stagePos_ >= static_cast<double>(params_.decayFrames)) {
@@ -237,7 +246,7 @@ private:
} }
case Stage::Sustain: case Stage::Sustain:
level_ = params_.sustainLevel; level_ = stageLevel(params_);
return level_; return level_;
case Stage::Release: { case Stage::Release: {
@@ -246,9 +255,7 @@ private:
stage_ = Stage::Finished; stage_ = Stage::Finished;
return 0.0; return 0.0;
} }
const double t = stagePos_ / static_cast<double>(params_.releaseFrames); level_ = stageLevel(params_);
level_ = releaseFrom_ * (1.0 - t);
if (level_ < 0.0) level_ = 0.0;
const double out = level_; const double out = level_;
stagePos_ += 1.0; stagePos_ += 1.0;
if (stagePos_ >= static_cast<double>(params_.releaseFrames)) { if (stagePos_ >= static_cast<double>(params_.releaseFrames)) {
@@ -348,6 +355,15 @@ public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; } void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0.0; }
void noteOn() { pos_ = 0.0; smooth_.clear(); } 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 // 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 // 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 // level, not a duration). `enabled` is a discrete toggle and travels by reload, so it is
+14 -1
View File
@@ -52,6 +52,16 @@ LiveValues foldLive(const PlayParams& params);
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good // reader: after the retry budget it reports "nothing new" and the caller keeps its last good
// snapshot rather than spinning on the audio thread. // 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 // 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 // host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign. // drive it later without a redesign.
@@ -64,7 +74,10 @@ public:
std::atomic_thread_fence(std::memory_order_release); std::atomic_thread_fence(std::memory_order_release);
values_ = values; values_ = values;
std::atomic_thread_fence(std::memory_order_release); std::atomic_thread_fence(std::memory_order_release);
seq_.store(gen + 2, std::memory_order_release); // even: complete and coherent // 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 // Copies the block into `out` and returns the generation actually observed, or 0 when
+16 -5
View File
@@ -197,13 +197,24 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { 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 // Gate's amplitude envelope is the AHDSR; Trigger's fade shape is anchored to a play span
// resolved at note-on and is not a live control, so it is deliberately untouched here. // resolved at note-on and travels by reload instead (deck_groups.h names why).
if (playMode_ == PlayMode::Gate) env_.applyLive(live.adsr); //
pitchEnv_.applyLive(live.pitchEnvAttackFrames, live.pitchEnvDecayFrames, // A fresh note and a sounding one take DIFFERENT envelope entry points, never one with a
live.pitchEnvPeakSemitones); // 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 (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
filterEnv_.applyLive(live.filterEnv); if (snap) filterEnv_.snapLive(live.filterEnv);
else filterEnv_.applyLive(live.filterEnv);
filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm); filterCutoffNorm_ = static_cast<double>(live.filterSettings.cutoffNorm);
filterKeyTrack_ = live.filterKeyTrack; filterKeyTrack_ = live.filterKeyTrack;
filterSettings_.morphLaw = live.filterSettings.morphLaw; filterSettings_.morphLaw = live.filterSettings.morphLaw;
+2 -2
View File
@@ -119,8 +119,8 @@ public:
// Applies the live-parameter block to a voice that is already sounding (or, with `snap`, // 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 // 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 // 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: a fresh note has // from here rather than jumping to it. `snap` takes the values outright — glides AND
// nothing to glide from, and its latched copy may predate the newest edit. // 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 // 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. // pitch ratio, and the decoded PCM stay latched at note-on.
+32 -1
View File
@@ -119,9 +119,40 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kFilterEnvSustain: case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease: case DeckParam::kFilterEnvRelease:
return true; return true;
default: // 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;
} }
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<int>(DeckParam::kCount) &&
isLiveDeckParam(static_cast<DeckParam>(paramId));
case LiveDragKind::kEnvNode:
return playMode == PlayMode::Gate;
case LiveDragKind::kOther:
return false;
}
return false;
} }
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+27 -7
View File
@@ -77,15 +77,35 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// controls, so this is a routing decision at the editor's commit site rather than a property // 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. // of any one knob; moving a control across the line is a change here and nowhere else.
// //
// Deliberately NOT live, each for its own reason: the discrete toggles (play mode, pitch // THE home for why each excluded control is excluded. Five continuous controls are outside the
// engine, filter enable/law, pitch-envelope enable) name a different sound rather than a // live set, plus every discrete toggle:
// different setting of one; the three capture-anchored overrides (root, loop span, start // - the discrete toggles (play mode, pitch engine, filter enable/law, pitch-envelope enable)
// frame) name positions in the decoded PCM; pitch key-track and the velocity->cutoff depth // name a different sound rather than a different setting of one;
// feed values a voice latches at note-on by design (the pitch ratio and the velocity-curve // - the three capture-anchored overrides (root, loop span, start frame) name positions in
// result), so making them live would retune or re-gain a note already struck; and Trigger's // the decoded PCM;
// %-length and fades resolve a play span that is a fact about the note. // - 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); 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 // 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 // +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. // centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
+5 -5
View File
@@ -68,7 +68,8 @@ scattered `#ifdef`s in the VST shell, except the one described below).
(`DEF_CLASS2` / `INLINE_UID` / `FUID` from `pluginfactory.h` + `funknown.h`). (`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 **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` predicate — see 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. `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. 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 2. **Engine rebuild**`rebuildVoiceEngine`: same drain-slot swap around the already-decoded
@@ -76,10 +77,9 @@ which route a control takes is decided once, by the pure `isLiveDeckParam` predi
3. **Live**`publishLiveParams` (and `masterGain_`, the original of the shape): a lock-free 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. publish the audio thread observes at block boundaries. No rebuild, no snapshot, no disk.
`liveParams_` is declared ahead of the instrument slots so it outlives every snapshot pointing at The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still writes the
it. The editor's `commitLive` is the tier-3 peer of `commitAndReload` and still writes the parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is
parameter set, so persistence is unchanged — `getState` serializes `params_`, never a reload declared ahead of the instrument slots at that member in `reasampler_processor.h`.
artifact, and the reload was never the persistence trigger.
**Non-goals / guardrails.** **Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a - The instrument never captures and never inserts into the arrange. Playback is a
+1 -1
View File
@@ -105,7 +105,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
return; return;
} }
// A live control already reached the voices during the drag; its release commits the // A live control already reached the voices during the drag; its release commits the
// final value the same way — no bridge read, no WAV re-decode, no snapshot rebuild. // final value through the same tier.
if (dragCommitsLive(kind, paramId)) { if (dragCommitsLive(kind, paramId)) {
commitLive(); commitLive();
invalidate(); invalidate();
+16 -6
View File
@@ -205,8 +205,8 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
case WM_RBUTTONUP: case WM_RBUTTONUP:
return 0; // claimed so the pair never reaches DefWindowProc (no context menu) return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
case WM_CAPTURECHANGED: case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore params_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset // 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. // 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). // Mirror of the panel shell's WM_CAPTURECHANGED handler (panel_window.cpp).
if (self) { if (self) {
@@ -219,15 +219,25 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
} }
if (self->drag_ != DragKind::kNone) { if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 / // A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (they mutate no parameter, so // voice count / master gain) mutate no parameter, so dragStartParams_ is
// dragStartParams_ is not a rollback target) — reset drag state only. // not a rollback target for them — reset drag state only. Every
// Every parameter-editing drag rolls its live mutation back to the snapshot. // parameter-editing drag restores the pre-grab snapshot.
const bool transient = self->drag_ == DragKind::kScrollThumb || const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob && (self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 || (self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) || self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain))); self->dragParamId_ == static_cast<int>(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->drag_ = DragKind::kNone;
self->dragParamId_ = -1; self->dragParamId_ = -1;
self->curvePointIndex_ = -1; // curve-node drag state (peer reset) self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
+8 -7
View File
@@ -144,13 +144,14 @@ void ReaSamplerEditor::commitLive() {
} }
bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const { bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
if (kind == DragKind::kDeckKnob) { // The decision itself is the pure liveCommitFor's; this is only the shell's drag-kind
// Negative ids are the processor-side sentinels (preview velocity), not parameter-set // vocabulary mapped onto it, so the routing is pinned by deck_groups' tests rather than
// controls, so they never reach the enum. // by inspection of this file.
return paramId >= 0 && using instrument::ui::LiveDragKind;
instrument::ui::isLiveDeckParam(static_cast<ParamControl>(paramId)); const LiveDragKind k = kind == DragKind::kDeckKnob ? LiveDragKind::kDeckKnob
} : kind == DragKind::kEnvNode ? LiveDragKind::kEnvNode
return kind == DragKind::kEnvNode && params_.play.playMode == PlayMode::Gate; : LiveDragKind::kOther;
return instrument::ui::liveCommitFor(k, paramId, params_.play.playMode);
} }
void ReaSamplerEditor::loadSelection(const std::string& id) { void ReaSamplerEditor::loadSelection(const std::string& id) {
+7
View File
@@ -153,6 +153,13 @@ void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
} }
void ReaSamplerProcessor::publishLiveParams() { void ReaSamplerProcessor::publishLiveParams() {
// reloadMutex_ enforces the block's SINGLE-WRITER contract (live_params.h), not the
// reload's slot bookkeeping: reloadInstrument publishes the block too, and two concurrent
// seqlock writers can leave the generation even mid-write, which a reader would accept as
// a coherent — but torn — block. The audio thread never takes this mutex, so the cost is
// an off-thread wait behind a reload. Lock order matches reloadInstrument's
// (reloadMutex_ then paramsMutex_, taken by instrumentParams below).
std::lock_guard<std::mutex> lock(reloadMutex_);
const int rate = builtSampleRate_.load(std::memory_order_relaxed); const int rate = builtSampleRate_.load(std::memory_order_relaxed);
if (rate <= 0) return; if (rate <= 0) return;
liveParams_.publish( liveParams_.publish(
+11 -5
View File
@@ -154,11 +154,10 @@ public:
// Republishes the live-parameter block from the stored parameter set, resolved against the // 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 // rate the loaded capture was built at so an unmoved value folds to exactly the frames the
// voices already latched. THE tier-3 commit: no bridge read, no WAV re-decode, no engine // voices already latched. THE tier-3 commit (the three tiers are listed in this
// rebuild, no snapshot swap — the sounding note follows the knob. Persistence is // directory's CLAUDE.md). Callers pair this with setInstrumentParams exactly as they
// unaffected: getState still serializes params_, so callers pair this with // paired it with reloadInstrument. No-op before anything has been decoded (the next reload
// setInstrumentParams exactly as they paired it with reloadInstrument. No-op before // bakes and publishes). UI thread; serialized against reloadInstrument's own publish.
// anything has been decoded (the next reload bakes and publishes). UI thread.
void publishLiveParams(); void publishLiveParams();
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
@@ -243,6 +242,13 @@ private:
// The rate the loaded capture was decoded/built at, so a live republish resolves the // 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 // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing
// built yet. // 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<int> builtSampleRate_{0}; std::atomic<int> builtSampleRate_{0};
// --- The audio-thread handoff (drain slot) --- // --- The audio-thread handoff (drain slot) ---
+46 -11
View File
@@ -2,8 +2,9 @@
// framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH // 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), // 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 // 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 // inside the floor window, the hit-test reaching the new filter controls, the bipolar knob
// law's inverse pair. // 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/deck_groups.h"
#include "../src/core/instrument/ui/sample_bands.h" #include "../src/core/instrument/ui/sample_bands.h"
@@ -186,7 +187,7 @@ static void testBipolarKnobLawRoundTripsAndIsExactAtCentre() {
CHECK(deckNormFromBipolar(3.0) == 1.0); CHECK(deckNormFromBipolar(3.0) == 1.0);
} }
static void testLiveRoutingCoversEveryContinuousPlaybackControl() { static void testEveryDeckControlIsClassifiedLiveOrReloading() {
// The live set: the six filter tone/modulation knobs, plus every stage time and stage // The live set: the six filter tone/modulation knobs, plus every stage time and stage
// level on all three envelopes. // level on all three envelopes.
const DeckParam live[] = { const DeckParam live[] = {
@@ -200,9 +201,8 @@ static void testLiveRoutingCoversEveryContinuousPlaybackControl() {
}; };
for (DeckParam p : live) CHECK(isLiveDeckParam(p)); for (DeckParam p : live) CHECK(isLiveDeckParam(p));
// Everything else reloads or rebuilds. kFilterVel and kKeyTrack are the two that look // Everything else reloads or rebuilds; deck_groups.h is the home for why each exclusion
// continuous but feed values a voice latches at note-on (the velocity-curve result and // is excluded.
// the pitch ratio) — making them live would re-gain or retune a note already struck.
const DeckParam reloads[] = { const DeckParam reloads[] = {
DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable,
DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel, DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel,
@@ -212,14 +212,49 @@ static void testLiveRoutingCoversEveryContinuousPlaybackControl() {
}; };
for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p)); for (DeckParam p : reloads) CHECK(!isLiveDeckParam(p));
// Every id in the space is classified by one of the two lists above, so a control added // COVERAGE, not cardinality: every id appears in EXACTLY ONE of the two lists. A sum check
// later cannot slip through unclassified. // would stay green if an edit duplicated one id and dropped another, leaving that one
const std::size_t classified = (sizeof(live) + sizeof(reloads)) / sizeof(DeckParam); // unclassified.
CHECK(classified == static_cast<std::size_t>(DeckParam::kCount)); for (int i = 0; i < static_cast<int>(DeckParam::kCount); ++i) {
const DeckParam p = static_cast<DeckParam>(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<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
// A knob's routing is the knob's, not the play mode's.
CHECK(liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kAttack),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(DeckParam::kTrigFadeIn),
PlayMode::Trigger));
CHECK(!liveCommitFor(LiveDragKind::kDeckKnob, static_cast<int>(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<int>(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<int>(DeckParam::kFilterCutoff),
PlayMode::Gate));
} }
int main() { int main() {
testLiveRoutingCoversEveryContinuousPlaybackControl(); testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight(); testDeckReadsPitchThenFilterThenAmpLeftToRight();
testFilterGroupCarriesItsFiveToneControlsPlusModulation(); testFilterGroupCarriesItsFiveToneControlsPlusModulation();
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
+375 -53
View File
@@ -1,9 +1,10 @@
// Standalone tests for LIVE PARAMETER DELIVERY into a sounding voice — no VST3, no REAPER, no // 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 // 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 // reaches the audio: the mid-stage rule holds normalized position, a level move glides, a
// filter knob moves the note that is already playing, two snapshots sharing one block behave // fresh note takes the newest block outright, every stage time and stage level on all three
// identically (the drain slot), what stays latched at note-on stays latched, and an unmoved // envelopes moves the note already sounding, a filter knob does too, two snapshots sharing one
// block renders byte-identically to the engine with no block at all. // 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 "../src/core/instrument/engine/voice_engine.h"
@@ -48,6 +49,62 @@ static double maxAbsDelta(const std::vector<AudioSample>& v, std::size_t from, s
return worst; return worst;
} }
static double peakOf(const std::vector<AudioSample>& 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<double>(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;
s.play.filter.env.sustainLevel = 1.0;
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<AudioSample> 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<std::size_t>(blockFrames));
}
return r;
}
// --- The mid-stage rule (candidate iv): hold normalized stage position ------------------ // --- The mid-stage rule (candidate iv): hold normalized stage position ------------------
static void testStageDurationChangeHoldsPhase() { static void testStageDurationChangeHoldsPhase() {
@@ -120,7 +177,10 @@ static void testSustainLevelChangeGlides() {
double v = 0.0; double v = 0.0;
for (int i = 0; i < 600; ++i) { for (int i = 0; i < 600; ++i) {
v = env.tick(); v = env.tick();
if (i == 0) CHECK(v == 1.0); // the first frame reproduces the pre-change level exactly // 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); const double step = std::fabs(v - prev);
if (step > worstStep) worstStep = step; if (step > worstStep) worstStep = step;
prev = v; prev = v;
@@ -162,6 +222,222 @@ static void testPitchEnvelopeHoldsPhaseAndGlidesDepth() {
CHECK(c.tick() == 0.0); 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 = &block;
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<AudioSample> 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<AudioSample> 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<AudioSample> 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<double>(edited.out[i]) -
static_cast<double>(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 --------------------------------- // --- The filter DSP's glide property, finally exercised ---------------------------------
static void testCutoffMoveAcrossPrepareDoesNotStep() { static void testCutoffMoveAcrossPrepareDoesNotStep() {
@@ -203,39 +479,6 @@ static void testCutoffMoveAcrossPrepareDoesNotStep() {
// --- Delivery into a sounding voice ------------------------------------------------------ // --- Delivery into a sounding voice ------------------------------------------------------
// Renders `blocks` blocks of `blockFrames` through a one-voice engine over `sample`, applying
// `mutate` to the published block after `changeAfter` blocks. Voice-major render order is the
// engine's, so a fixed block size is what makes two runs comparable.
struct Run {
std::vector<AudioSample> 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.
static Run renderWithLive(SampleData& sample, LiveParams* block, int blockFrames, int blocks,
int changeAfter, const LiveValues* changed) {
sample.live = block;
if (block) block->publish(foldLive(sample.play));
VoiceEngine engine(1, sample);
engine.noteOn(72, 100);
Run r;
for (int b = 0; b < blocks; ++b) {
if (block && changed && b == changeAfter) block->publish(*changed);
engine.render(r.out, static_cast<std::size_t>(blockFrames));
}
return r;
}
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;
s.play.filter.env.sustainLevel = 1.0;
return s;
}
static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() { static void testUnmovedBlockIsByteIdenticalToNoBlockAtAll() {
SampleData bare = filteredSine(); SampleData bare = filteredSine();
SampleData blocked = filteredSine(); SampleData blocked = filteredSine();
@@ -290,10 +533,6 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
} }
CHECK(preChangeIdentical); CHECK(preChangeIdentical);
// And it glided rather than stepping. Measured against the signal's OWN local scale
// frame by frame, because a resonant sweep legitimately grows the output as the corner
// passes the tone — an absolute delta bound would flag that as a click. A step shows
// up instead as one frame far outside the range its own neighbourhood was moving in.
// And it ARRIVED as a glide, not as a step. Measured as how far the swept render has // 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 // 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 // far it departs once settled: a glide has barely begun to diverge, a snapped delivery
@@ -303,8 +542,21 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
// TPT filter preserves state across prepare(), so even an instantaneous coefficient // TPT filter preserves state across prepare(), so even an instantaneous coefficient
// jump produces no isolated output spike — measured, by defeating the ramp and // jump produces no isolated output spike — measured, by defeating the ramp and
// re-running, the spike statistic was unchanged while these two numbers converged. // 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<std::size_t>(instrument::engine::kLiveRampSeconds * kRate);
const std::size_t window = rampFrames / 240;
const double kGlideMargin = 42.0;
const double bound = kGlideMargin * static_cast<double>(window) /
static_cast<double>(rampFrames);
double immediate = 0.0; double immediate = 0.0;
for (std::size_t i = 512 * 8; i < 512 * 8 + 16; ++i) { for (std::size_t i = 512 * 8; i < 512 * 8 + window; ++i) {
immediate = (std::max)(immediate, std::fabs(static_cast<double>(swept.out[i]) - immediate = (std::max)(immediate, std::fabs(static_cast<double>(swept.out[i]) -
static_cast<double>(baseline.out[i]))); static_cast<double>(baseline.out[i])));
} }
@@ -313,15 +565,18 @@ static void testEveryLiveFilterControlMovesTheSoundingNote() {
settled = (std::max)(settled, std::fabs(static_cast<double>(swept.out[i]) - settled = (std::max)(settled, std::fabs(static_cast<double>(swept.out[i]) -
static_cast<double>(baseline.out[i]))); static_cast<double>(baseline.out[i])));
} }
if (!(immediate <= settled * 0.4)) std::printf(" (glide: %s %f vs %f)\n", c.name, if (!(immediate <= settled * bound))
immediate, settled); std::printf(" (glide: %s ratio %f vs bound %f)\n", c.name,
CHECK(immediate <= settled * 0.4); settled > 0.0 ? immediate / settled : -1.0, bound);
CHECK(immediate <= settled * bound);
} }
} }
static void testDrainSlotVoiceTracksTheSameBlock() { static void testOneBlockServesTwoIndependentObservers() {
// Two snapshots, one block — exactly the processor's live_/draining_ shape. A note ringing // Two snapshots, one block — exactly the processor's live_/draining_ shape. The claim is
// out of the displaced snapshot must answer the knob identically to a live one. // 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 liveSnapshot = filteredSine();
SampleData drainSnapshot = filteredSine(); SampleData drainSnapshot = filteredSine();
LiveParams block; LiveParams block;
@@ -348,18 +603,27 @@ static void testDrainSlotVoiceTracksTheSameBlock() {
if (a[i] != b[i]) { same = false; break; } if (a[i] != b[i]) { same = false; break; }
} }
CHECK(same); CHECK(same);
// Non-tautological: the shared block genuinely moved the sound, so "identical" is a claim // The shared block genuinely moved the sound, so "identical" is a claim about both
// about the drain tracking, not about nothing having happened. // observers having seen it rather than about nothing having happened.
double moveEnergy = 0.0; double moveEnergy = 0.0;
for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]); for (std::size_t i = 512 * 12; i < a.size(); ++i) moveEnergy += std::fabs(a[i]);
CHECK(moveEnergy > 1.0); 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 ------------------------------------------------------- // --- What stays latched at note-on -------------------------------------------------------
static void testVelocityNoteAndPitchStayLatched() { static void testPitchRatioAndVelocityGainStayLatched() {
// A ramp source read under Varispeed: every output frame is (source at readPos) * velocity // 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. // 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; SampleData s;
s.frames.resize(100000); s.frames.resize(100000);
for (std::size_t i = 0; i < s.frames.size(); ++i) { for (std::size_t i = 0; i < s.frames.size(); ++i) {
@@ -422,16 +686,74 @@ static void testVelocityNoteAndPitchStayLatched() {
CHECK(std::fabs(static_cast<double>(out2.back()) - static_cast<double>(out.back())) > 1e-4); CHECK(std::fabs(static_cast<double>(out2.back()) - static_cast<double>(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<double>(atQuiet.out[i]) -
static_cast<double>(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<double>(atLoud.out[i]) -
static_cast<double>(atQuiet.out[i]) * ratio) > 1e-6) {
proportional = false;
break;
}
}
CHECK(proportional);
}
int main() { int main() {
testStageDurationChangeHoldsPhase(); testStageDurationChangeHoldsPhase();
testShortenedStageStillLandsContinuously(); testShortenedStageStillLandsContinuously();
testSustainLevelChangeGlides(); testSustainLevelChangeGlides();
testPitchEnvelopeHoldsPhaseAndGlidesDepth(); testPitchEnvelopeHoldsPhaseAndGlidesDepth();
testAFreshEnvelopeTakesANewlyDialledStageTimeOutright();
testAFreshPitchEnvelopeTakesTheNewTimesOutright();
testCutoffMoveAcrossPrepareDoesNotStep(); testCutoffMoveAcrossPrepareDoesNotStep();
testUnmovedBlockIsByteIdenticalToNoBlockAtAll(); testUnmovedBlockIsByteIdenticalToNoBlockAtAll();
testANoteStartedAfterAPublishSoundsThePublishedEnvelope();
testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote();
testEveryLiveFilterControlMovesTheSoundingNote(); testEveryLiveFilterControlMovesTheSoundingNote();
testDrainSlotVoiceTracksTheSameBlock(); testOneBlockServesTwoIndependentObservers();
testVelocityNoteAndPitchStayLatched(); testPitchRatioAndVelocityGainStayLatched();
testVelocityGainSurvivesAHostilePublishThatReallyLands();
if (g_fail == 0) std::printf("live_delivery tests passed\n"); if (g_fail == 0) std::printf("live_delivery tests passed\n");
return g_fail == 0 ? 0 : 1; return g_fail == 0 ? 0 : 1;
} }