Make spline EG drags audible live: held and new notes follow a dragged knot

Contours ride their own seqlock block into a double-buffered engine copy, and the swap step is smoothed. Each drag move publishes contours without a model write; release still commits and reloads.
This commit is contained in:
2026-09-25 13:42:25 -04:00
parent 9ff34b0a9e
commit f7505d0e53
21 changed files with 788 additions and 97 deletions
+13 -4
View File
@@ -203,6 +203,14 @@ automatable parameters."* It rejects the precedent, not one instance of it.
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the - **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 voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
the envelope's own bounded offset smoother. the envelope's own bounded offset smoother.
- **A drawn contour is live too, through a second block.** `LiveContours` (all three contours
as fixed-capacity arrays, ~9 KB) is published beside `LiveValues` rather than inside it, so a
knob publish, the automation merge and the block compare never carry it. Each `VoiceEngine`
double-buffers it, and `applyContoursToActive` (`voice_engine.h`) owns the rebind invariant
that makes the double buffer safe. A cursor `start()` left cleared (a Staged EG) stays
cleared: Staged|Spline is a note-on fact. A contour is positional, like the Trigger AHD,
so a swap is a level step at the read position, absorbed by a per-EG `StepSmoother` — one more
predicted branch per sample on a drawn EG, none on a Staged one.
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md) ### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
@@ -255,8 +263,9 @@ consequences, each with one home:
- **Normalized is what makes a contour length-independent.** There are no stored seconds to - **Normalized is what makes a contour length-independent.** There are no stored seconds to
rescale, so a different-length capture replays the same shape proportionally. rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope - **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
structs.** Those are copied whole into the live block, which must stay trivially copyable structs.** Those are copied whole into the live block, which must stay small and trivially
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it copyable (`live_params.h`). A contour still reaches the note already sounding — through its
OWN block (`live_contours.h`), see "Live parameter delivery". Only the Staged|Spline mode
travels by reload. travels by reload.
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:** **Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
@@ -294,7 +303,7 @@ anything for a trigger shape.
- 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. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead. - `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. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead. - `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
- `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), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), 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), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), 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. The same library carries `live_contours.h`/`.cpp` (the drawn-contour block beside it, and `foldContours`, templated over both parameter representations like `splineActive`) and the header-only `seqlock.h`, the ONE seqlock both blocks publish through.
- `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`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot. - `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`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
- `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. - `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.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
@@ -396,7 +405,7 @@ anything for a trigger shape.
envelope that was never running. 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
`snapLive` before its first frame. `snapLive` (and a drawn EG by `applyContours`' snap) before its first frame.
- **`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
+8 -3
View File
@@ -26,10 +26,11 @@ add_subdirectory(filter)
# marker layer. After filter: it links play_params' dependency set, which includes it. # marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop) add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no # The live-parameter block and the drawn-contour block beside it: value layers plus their
# engine — the block is a plain value the voice observes, not a thing the engine owns. # publication, deliberately linking no engine — a block is a plain value the voice observes, not
# a thing the engine owns.
reasampler_pure_library(live_params reasampler_pure_library(live_params
SOURCES live_params.cpp SOURCES live_params.cpp live_contours.cpp
LINK PUBLIC peaks velocity_curve filter curve_law) LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params) reasampler_test(live_params LINK live_params)
@@ -52,6 +53,10 @@ reasampler_test(sampler_filter LINK sampler_core)
# does to a voice that is already sounding, and what it must leave alone. # does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core) reasampler_test(live_delivery LINK sampler_core)
# The drawn-contour half of live delivery. Its own executable because it replaces the global
# allocator to prove the audio-side swap allocates nothing, which must not leak into other tests.
reasampler_test(live_contours LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD # The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour. # both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core) reasampler_test(staged_envelopes LINK sampler_core)
@@ -0,0 +1,16 @@
// live_contours.cpp — the VelocityCurve -> fixed-capacity copy the contour block carries.
#include "core/instrument/engine/live_contours.h"
namespace reasampler::instrument::engine {
ContourPoints contourPoints(const VelocityCurve& curve) {
ContourPoints out{};
const std::vector<VelocityPoint>& pts = curve.points();
const std::size_t n = pts.size() < kMaxCurvePoints ? pts.size() : kMaxCurvePoints;
for (std::size_t i = 0; i < n; ++i) out.points[i] = pts[i];
out.count = static_cast<std::uint32_t>(n);
return out;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,50 @@
#pragma once
// live_contours.h — the three drawn EG contours as one fixed-capacity plain value, and the block
// that publishes them to the audio thread so a sounding voice follows a contour edit. Separate
// from LiveValues on purpose: at kMaxCurvePoints x 3 this is ~9 KB, and riding the live block
// would put it on every knob publish, automation merge and field compare.
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/seqlock.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler::instrument::engine {
// One contour's points, copied out of its VelocityCurve so the audio thread can hold it without
// a vector. `count` never exceeds kMaxCurvePoints — the curve's own ceiling — and the points
// past it are ignored.
struct ContourPoints {
std::uint32_t count = 0;
VelocityPoint points[kMaxCurvePoints];
};
// All three are carried whatever each envelope's mode: Staged|Spline is a note-on fact that
// travels by reload, so the voice decides which cursors to rebind and the block stays one shape.
struct LiveContours {
ContourPoints amp;
ContourPoints pitch;
ContourPoints filter;
};
static_assert(std::is_trivially_copyable_v<LiveContours>,
"the contour block is copied under a seqlock — it must stay a plain value");
ContourPoints contourPoints(const VelocityCurve& curve);
// Templated over the frames and seconds parameter representations for the same reason
// splineActive is (play_params.h): both spell the three fields identically. Off the audio thread.
template <class Play>
LiveContours foldContours(const Play& p) {
LiveContours c{};
c.amp = contourPoints(p.ampSpline.contour);
c.pitch = contourPoints(p.pitchSpline.contour);
c.filter = contourPoints(p.filterSpline.contour);
return c;
}
// A class rather than an alias so play_params.h can forward-declare it.
class LiveContourBlock : public Seqlock<LiveContours> {};
} // namespace reasampler::instrument::engine
+6 -61
View File
@@ -1,15 +1,15 @@
#pragma once #pragma once
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread // 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 // observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the // 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 // per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
// instrument snapshot (see SampleData::live). // instrument snapshot (see SampleData::live).
#include <atomic>
#include <cstdint> #include <cstdint>
#include <type_traits> #include <type_traits>
#include "core/instrument/engine/play_params.h" #include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/seqlock.h"
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
@@ -96,64 +96,9 @@ inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a ==
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit. // PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack); LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd // The live-parameter block's publication — seqlock.h owns the protocol and its single-writer
// and an even generation; the reader copies the block and re-checks the generation, retrying // contract. A class rather than an alias so play_params.h can forward-declare it.
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the class LiveParams : public Seqlock<LiveValues> {};
// 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
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// 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<std::uint32_t> seq_{0};
LiveValues values_{};
};
// Linear per-frame glide with EXACT termination: once the target is within one step the value // 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, // becomes the target itself. An asymptotic smoother would leave the value forever a hair off,
+7 -3
View File
@@ -15,7 +15,8 @@
namespace reasampler { namespace reasampler {
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer // SampleData holds a pointer to each (live_params.h, live_contours.h).
namespace instrument::engine { class LiveParams; class LiveContourBlock; }
using audio::AudioSample; using audio::AudioSample;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
@@ -181,8 +182,8 @@ struct PlayParams {
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to // The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope // filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
// structs because those are copied whole into the live block, which must stay trivially // structs because those are copied whole into the live block, which must stay trivially
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity // copyable (live_params.h). A contour reaches a sounding voice through its own block
// curves it travels by reload. // instead (live_contours.h); only its Staged|Spline mode travels by reload.
SplineEnv ampSpline; SplineEnv ampSpline;
SplineEnv pitchSpline; SplineEnv pitchSpline;
SplineEnv filterSpline; SplineEnv filterSpline;
@@ -295,6 +296,9 @@ struct SampleData {
// same knob as a live one. That is the desired behaviour — it is the note the user is // 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. // hearing. Do not "fix" it by moving ownership into the snapshot.
const instrument::engine::LiveParams* live = nullptr; const instrument::engine::LiveParams* live = nullptr;
// The drawn-contour peer of `live`, under the same ownership and null contract: null plays
// the contours in `play` for the note's whole life.
const instrument::engine::LiveContourBlock* liveContours = nullptr;
// A framesR of a different length than frames is treated as absent — a malformed pair // A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays. // never half-plays.
+76
View File
@@ -0,0 +1,76 @@
#pragma once
// seqlock.h — the lock-free publication every live block rides from its off-thread writer to
// the audio thread: a single-writer seqlock over one plain value. Generic over that value so the
// live-parameter block and the drawn-contour block share one implementation of the protocol.
#include <atomic>
#include <cstdint>
#include <type_traits>
namespace reasampler::instrument::engine {
// Single-writer seqlock. The writer publishes a whole block between an odd and an even
// generation; a 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. Reading does not mutate, so any number of readers may share one
// block (the live and the draining engine both do).
//
// 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.
template <class T>
class Seqlock {
static_assert(std::is_trivially_copyable_v<T>,
"the block is copied under a seqlock — it must stay a plain value");
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const T& 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
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// 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(T& 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<std::uint32_t> seq_{0};
T values_{};
};
} // namespace reasampler::instrument::engine
+10 -7
View File
@@ -224,17 +224,20 @@ private:
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the // sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling. // 128-point ceiling.
// //
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve // Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime. // cursor, or rebinds it first. The voice binds against its SampleData, which has exactly that
// lifetime, or against its engine's live contour copy, which the engine rebinds before reusing.
class SplineCursor { class SplineCursor {
public: public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so // Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
// the caller's `if (active())` skips the whole spline path. // the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) { void bind(const VelocityCurve& c) { bind(c.points().data(), c.points().size()); }
const std::vector<VelocityPoint>& pts = c.points(); // The same over a raw X-ordered array (live_contours.h's fixed-capacity copy). Rebinding a
if (pts.size() < 2) { clear(); return; } // cursor mid-read is legal: it restarts the segment cache, and the next eval re-locates.
pts_ = pts.data(); void bind(const VelocityPoint* pts, std::size_t n) {
n_ = pts.size(); if (pts == nullptr || n < 2) { clear(); return; }
pts_ = pts;
n_ = n;
select(0); select(0);
} }
void clear() { pts_ = nullptr; n_ = 0; } void clear() { pts_ = nullptr; n_ = 0; }
+26
View File
@@ -109,6 +109,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else { } else {
filterSplineCur_.clear(); filterSplineCur_.clear();
} }
ampSplineSmooth_.clear();
pitchSplineSmooth_.clear();
filterSplineSmooth_.clear();
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to // Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span. // frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
@@ -330,6 +333,29 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
rMorph_.moving() || rDrive_.moving(); rMorph_.moving() || rDrive_.moving();
} }
namespace {
// One EG's half of Voice::applyContours. `before` is read through the OLD binding, which is
// still intact: the engine only overwrites a buffer no active voice is bound to.
void rebindContour(SplineCursor& cursor, StepSmoother& smooth,
const instrument::engine::ContourPoints& pts, double phase, bool snap) {
if (!cursor.active()) return;
const double before = snap ? 0.0 : cursor.eval(phase);
cursor.bind(pts.points, pts.count);
if (snap || !cursor.active()) return;
const double after = cursor.eval(phase);
if (after != before) smooth.absorb(before - after);
}
} // namespace
void Voice::applyContours(const instrument::engine::LiveContours& contours, bool snap) {
const double phase = splinePhase();
rebindContour(ampSplineCur_, ampSplineSmooth_, contours.amp, phase, snap);
rebindContour(pitchSplineCur_, pitchSplineSmooth_, contours.pitch, phase, snap);
rebindContour(filterSplineCur_, filterSplineSmooth_, contours.filter, phase, snap);
}
void Voice::retune(int note) { void Voice::retune(int note) {
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps // 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 // running (no re-attack), the read head keeps its position, the shifter keeps its ring
+32 -9
View File
@@ -15,6 +15,7 @@
#include "core/instrument/engine/envelopes.h" #include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h" #include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h" #include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h" #include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/pitch_shift.h"
@@ -179,6 +180,14 @@ public:
// pitch ratio, and the decoded PCM stay latched at note-on. // pitch ratio, and the decoded PCM stay latched at note-on.
void applyLive(const instrument::engine::LiveValues& live, bool snap); void applyLive(const instrument::engine::LiveValues& live, bool snap);
// Re-points every drawn EG this note is ALREADY reading at `contours` — a cursor start()
// left cleared stays cleared, because Staged|Spline is a note-on fact. Block-boundary only,
// like applyLive. Without `snap` the level step at the current read position is absorbed
// by that EG's StepSmoother, the same way a live reshape of a positional AHD is; with it
// (a voice that has rendered nothing) the new contour is taken outright. `contours` must
// outlive the binding or be rebound first — VoiceEngine's double buffer owns that.
void applyContours(const instrument::engine::LiveContours& contours, bool snap);
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off // 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 // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through. // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
@@ -268,6 +277,14 @@ private:
// its opening value. // its opening value.
double splinePhase() const { return readPos_ * splineScale_; } double splinePhase() const { return readPos_ * splineScale_; }
// One drawn EG's value this frame: the contour at the read position plus whatever step a
// live contour swap left to absorb (applyContours). The smoother is inactive at rest, so a
// contour nobody is editing costs one predicted branch over the bare cursor read.
double readSpline(SplineCursor& cursor, StepSmoother& smooth) {
const double v = cursor.eval(splinePhase());
return smooth.active() ? v + smooth.advance() : v;
}
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read // This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once // at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD // per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
@@ -284,7 +301,7 @@ private:
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) { if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's // Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why. // finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
amp = ampSplineCur_.eval(splinePhase()); amp = readSpline(ampSplineCur_, ampSplineSmooth_);
if (amp == 0.0 && ampSplineCur_.onFinalSegment() && if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
ampSplineCur_.segmentEndValue() == 0.0) { ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true; amplitudeDone_ = true;
@@ -321,7 +338,7 @@ private:
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted. // branch is perfectly predicted.
const double envOut = filterSplineCur_.active() const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase()) ? readSpline(filterSplineCur_, filterSplineSmooth_)
: ((playMode_ == PlayMode::Gate) : ((playMode_ == PlayMode::Gate)
? filterEnv_.tick() ? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset())); : filterAhd_.amplitudeAt(sourceOffset()));
@@ -514,9 +531,10 @@ private:
seedTerminalDeclick(); seedTerminalDeclick();
} }
const double gain = amp * velocityGain_; const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchSplineCur_.active() const double pitchEnvSemis =
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase()) pitchSplineCur_.active()
: pitchEnv_.tick(); ? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_)
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
// pow entirely — no per-frame transcendental on the common path. // pow entirely — no per-frame transcendental on the common path.
@@ -748,13 +766,18 @@ private:
bool amplitudeDone_ = false; // set when the active amplitude envelope finished bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the // The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor // SampleData outlives the voice — same contract as sample_) and, when the engine observes a
// inactive, so a purely staged instrument's per-sample path gains three predicted branches // contour block, re-pointed at its engine's live copy (applyContours). A Staged EG leaves its
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour // cursor inactive, so a purely staged instrument's per-sample path gains three predicted
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled. // branches and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every
// contour shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
// Each smoother holds the step a live contour swap left, in the contour's own y domain.
SplineCursor ampSplineCur_; SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_; SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_; SplineCursor filterSplineCur_;
StepSmoother ampSplineSmooth_;
StepSmoother pitchSplineSmooth_;
StepSmoother filterSplineSmooth_;
double splineScale_ = 0.0; double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0; double pitchSplineDepth_ = 0.0;
@@ -52,7 +52,25 @@ void VoiceEngine::applyLiveToActive() {
} }
} }
void VoiceEngine::applyContoursToActive() {
const instrument::engine::LiveContourBlock* block = sample_.liveContours;
if (block == nullptr) return; // bare engine: every voice keeps its snapshot's contours
const std::uint32_t published = block->generation();
if (published == 0 || published == contourGeneration_) return;
const std::size_t back = contourFront_ ^ 1u;
const std::uint32_t observed = block->read(contours_[back]);
if (observed == 0) return; // torn: the back buffer is discarded, retried next boundary
contourFront_ = back;
contourGeneration_ = observed;
haveContours_ = true;
for (Voice& voice : voices_) {
if (voice.active()) voice.applyContours(contours_[contourFront_], /*snap=*/false);
}
}
void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
// FIRST, so no sounding voice is left bound to a buffer the next read overwrites.
applyContoursToActive();
refreshLive(); refreshLive();
// THE read of the note-on-latched commit class, and the only one: a published block outranks // THE read of the note-on-latched commit class, and the only one: a published block outranks
// the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below // the snapshot's own copy (a live edit deliberately leaves that stale), and applyLive below
@@ -65,6 +83,8 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack, voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction); lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true); if (haveLive_) voice.applyLive(live_, /*snap=*/true);
// The snapshot's contours are stale after a live edit, exactly as its play params are.
if (haveContours_) voice.applyContours(contours_[contourFront_], /*snap=*/true);
voice.setStartOrder(nextStartOrder_++); voice.setStartOrder(nextStartOrder_++);
} }
@@ -258,6 +278,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// audio thread never touches the heap. // audio thread never touches the heap.
if (out == nullptr || frameCount == 0) return; if (out == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) { for (Voice& voice : voices_) {
if (!voice.active()) continue; if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) { for (std::size_t f = 0; f < frameCount; ++f) {
@@ -274,6 +295,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame). // discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return; if (left == nullptr || right == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) { for (Voice& voice : voices_) {
if (!voice.active()) continue; if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) { for (std::size_t f = 0; f < frameCount; ++f) {
+22
View File
@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include "core/audio/peaks.h" #include "core/audio/peaks.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h" #include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/play_params.h" #include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/voice.h" #include "core/instrument/engine/voice.h"
@@ -57,6 +58,11 @@ public:
MonoTrigger monoTrigger = MonoTrigger::Retrigger, MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false); bool takeoverDeclick = false);
// Voices may hold raw pointers into this engine's own contour buffers, so a copy would
// leave them reading the original's.
VoiceEngine(const VoiceEngine&) = delete;
VoiceEngine& operator=(const VoiceEngine&) = delete;
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the // MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an // index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error. // out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
@@ -124,6 +130,22 @@ private:
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
bool haveLive_ = false; bool haveLive_ = false;
// --- Drawn-contour observation (live_contours.h) ---
// Reads a new contour generation, when one landed, into the buffer no voice is bound to and
// rebinds EVERY active voice to it before returning — which is what keeps the other buffer
// free for the next read. That invariant is why this, and never a bare read, is what both
// the block boundary and startVoice call.
void applyContoursToActive();
// Double-buffered because a voice's SplineCursor points INTO the buffer: reading over it
// would change a contour under a sounding voice without the rebind that absorbs the step,
// and a torn read (which the seqlock only reports after the fact) must land somewhere no
// voice reads. ~9 KB each, in the engine, so nothing on the audio thread allocates.
std::array<instrument::engine::LiveContours, 2> contours_{};
std::size_t contourFront_ = 0;
std::uint32_t contourGeneration_ = 0; // last generation observed; 0 = none yet
bool haveContours_ = false;
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on // 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. // (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const; std::size_t activePreserveVoices() const;
+16 -4
View File
@@ -84,6 +84,16 @@ The editor's `commitLive` is the tier-3 peer of `commitAndReload`; why it still
parameter set is recorded at its declaration in `reasampler_editor.h`, and why `liveParams_` is 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`. declared ahead of the instrument slots at that member in `reasampler_processor.h`.
**The drawn-contour drag is the one gesture that publishes WITHOUT committing.** Each move
couriers the editor's contours to `liveContours_` (`publishLiveContours`) and writes nothing
else — no parameter set, no generation, no host callback — and its release is the
`commitAndReload` it always was. That is sound because no contour is a host parameter, so no
host-facing reader needs the model to carry it mid-gesture; it is what keeps a motion event off
the host entirely (an unlatched `setInstrumentParams` opens a group edit even when nothing
exposed moved). `reloadInstrument` republishes the model's contours, so a release, a click-edit
(add/delete/hard) and a restore all reach the voices through the reload; capture-lost, the one
exit that commits nothing, re-couriers the restored `params_`.
**The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The **The VST3 parameter surface is a THIRD surface onto the one model, never a second copy.** The
pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is pure half — the frozen id table, the exposed set, the plain-value layer, the formatter — is
`core/instrument/param` and is documented there; this directory only adapts it. `core/instrument/param` and is documented there; this directory only adapts it.
@@ -109,7 +119,8 @@ pure half — the frozen id table, the exposed set, the plain-value layer, the f
the audio thread and may allocate on the way; `process()` merges that block with the host's the audio thread and may allocate on the way; `process()` merges that block with the host's
automation points into `automationLive_`, which is what `SampleData::live` points at. Two automation points into `automationLive_`, which is what `SampleData::live` points at. Two
blocks rather than one because the seqlock's single-writer contract is load-bearing and the two blocks rather than one because the seqlock's single-writer contract is load-bearing and the two
writers genuinely differ in thread. writers genuinely differ in thread. The drawn-contour block `liveContours_` has no such pair:
no host automates a contour, so the engines read it straight from its off-thread writers.
### THE AUTHORITY MODEL — who may write a parameter's value, and until when ### THE AUTHORITY MODEL — who may write a parameter's value, and until when
@@ -123,6 +134,7 @@ the single authority.** Everything else that holds these values is a cache or a
| Writer | Authority begins | Authority ends | | Writer | Authority begins | Authority ends |
|---|---|---| |---|---|---|
| Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model | | Editor gesture (`commitLive` / `commitAndReload`) | mouse-down | the commit lands in the model |
| Contour drag's courier (`publishLiveContours`) | mouse-down | never writes the model — the release's `commitAndReload` does |
| Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) | | Host controller write (`setParamNormalized`) | the call | the call returns (it writes the model) |
| State restore (`setState`) | the call | the call returns | | State restore (`setState`) | the call | the call returns |
| Bake reset (`adoptBakedCapture`) | the call | the call returns | | Bake reset (`adoptBakedCapture`) | the call | the call returns |
@@ -130,9 +142,9 @@ the single authority.** Everything else that holds these values is a cache or a
| Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds | | Reload seed (`reloadInstrument`) | never — it does not write `params_` | it only republishes a live block folded from whatever the model already holds |
| **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** | | **Host automation point** (`IParameterChanges`) | the block it lands in | **the UI thread has folded it into the model and republished** |
Every writer above except the last two writes the model directly, so for those "authority ends" Every writer above except the contour courier and the last two writes the model directly, so for
is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never those "authority ends" is just "the write happened". Reload seed is not itself a model write —
touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193` `reloadInstrument` never touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193`
— which is why its row states no authority window of its own. The automation lane is the only one — which is why its row states no authority window of its own. The automation lane is the only one
that cannot write directly: the SDK delivers it on the audio thread, where the model path that cannot write directly: the SDK delivers it on the audio thread, where the model path
allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the allocates (`resolvePlay` copies velocity curves and spline contours). So it patches the
@@ -178,6 +178,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click // At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor. // rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true; if (index < 0) return true;
publishContoursLive(); // the new point is heard before the first move, not after it
} }
drag_ = DragKind::kSplineNode; drag_ = DragKind::kSplineNode;
curvePointIndex_ = index; curvePointIndex_ = index;
@@ -188,7 +189,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in
dragCurveRect_ = waveArea.rect; dragCurveRect_ = waveArea.rect;
dragStartX_ = x; dragStartX_ = x;
dragStartY_ = y; dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP invalidate(); // the model commit lands on WM_LBUTTONUP
return true; return true;
} }
@@ -235,7 +236,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag( splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_), dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_); splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release publishContoursLive(); // heard now; the model write stays the release's commit
invalidate();
return; return;
} }
+4
View File
@@ -312,6 +312,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
// drag used. // drag used.
if (self->dragCommitsLive(self->drag_, self->dragParamId_)) { if (self->dragCommitsLive(self->drag_, self->dragParamId_)) {
self->commitLive(); self->commitLive();
} else if (self->drag_ == DragKind::kSplineNode) {
// The contour drag reached the voices without the model; restore
// the voices to it the same way (see publishContoursLive).
self->publishContoursLive();
} }
} }
// Peer of onMouseUp's bracket close, and after the rollback for the same // Peer of onMouseUp's bracket close, and after the rollback for the same
+4
View File
@@ -251,6 +251,10 @@ void ReaSamplerEditor::commitLive() {
processor_->publishLiveParams(); processor_->publishLiveParams();
} }
void ReaSamplerEditor::publishContoursLive() {
if (processor_) processor_->publishLiveContours(params_.play);
}
bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) { bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) {
return paramId == -2 || return paramId == -2 ||
paramId == static_cast<int>(ParamControl::kVoiceCount) || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
@@ -153,6 +153,7 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// top of the first block after this, which is where the swap below becomes visible // top of the first block after this, which is where the swap below becomes visible
// too — so the new snapshot's first note reads it. // too — so the new snapshot's first note reads it.
sample.live = &automationLive_; sample.live = &automationLive_;
sample.liveContours = &liveContours_;
// BEFORE the publish: the publish is what makes the audio thread re-merge, and the // BEFORE the publish: the publish is what makes the audio thread re-merge, and the
// merge resolves every held automation stage time against this rate. Stored after, // merge resolves every held automation stage time against this rate. Stored after,
// a merge in the window between them would resolve them against the previous // a merge in the window between them would resolve them against the previous
@@ -164,6 +165,10 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// publishLiveParams never holds reloadMutex_, so this is the only nesting. // publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_); std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack)); liveParams_.publish(instrument::engine::foldLive(sample.play, sample.keyTrack));
// The model's contours, so a reload-tier contour edit (a point added, deleted or
// made hard, a drag's release) reaches the drain's sounding voices too, and a
// courier publish from an abandoned gesture cannot outlive the reload.
liveContours_.publish(instrument::engine::foldContours(sample.play));
} }
resolvedId = selId; // the concrete pick that resolved resolvedId = selId; // the concrete pick that resolved
} }
+8
View File
@@ -293,6 +293,14 @@ AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() {
return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation()); return AutomationChannel::ReleaseProof::fromPublish(before, liveParams_.generation());
} }
void ReaSamplerProcessor::publishLiveContours(const PlaySeconds& play) {
// Folded outside the lock, as publishLiveParams' block is. A contour is normalized time, so
// unlike that block it needs no rate and publishes even before anything is built.
const instrument::engine::LiveContours contours = instrument::engine::foldContours(play);
std::lock_guard<std::mutex> lock(livePublishMutex_);
liveContours_.publish(contours);
}
SampleRefs ReaSamplerProcessor::sampleRefs() { SampleRefs ReaSamplerProcessor::sampleRefs() {
std::lock_guard<std::mutex> lock(refsMutex_); std::lock_guard<std::mutex> lock(refsMutex_);
return sampleRefs_; return sampleRefs_;
+6
View File
@@ -267,6 +267,12 @@ private:
// the knob. Does not repaint; callers already do. UI thread only. // the knob. Does not repaint; callers already do. UI thread only.
void commitLive(); void commitLive();
// The drawn-contour drag's per-move publish: params_' three contours straight to the voices,
// with NO parameter-set write — why is this directory's CLAUDE.md (the contour-drag note
// under the commit tiers). Every path that ends such a drag without its release's
// commitAndReload — capture-lost — must call this again after restoring params_. UI thread.
void publishContoursLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is // Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per deckParamCommit; an envelope-node drag is live in EITHER mode — see // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see
// liveCommitFor (deck_groups.h) for why. // liveCommitFor (deck_groups.h) for why.
+15 -4
View File
@@ -23,6 +23,7 @@
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #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/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter #include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#include "core/instrument/engine/live_contours.h" // LiveContourBlock (the drawn-contour block)
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block) #include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume #include "core/instrument/engine/meter_accumulate.h" // the meter's block-rate folds + consume
#include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding #include "core/instrument/param/param_id.h" // the frozen ParamId space + DeckParam binding
@@ -31,6 +32,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState; using instrument::map::ComponentState;
using instrument::map::InstrumentParams; using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleRefEntry; using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs; using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault; using instrument::map::kPreviewVelocityDefault;
@@ -206,6 +208,11 @@ public:
// bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it. // bakes and publishes). UI thread; serialized against reloadInstrument's own publish. Returns drainAutomationToModel's release proof (automation_channel.h); other callers ignore it.
AutomationChannel::ReleaseProof publishLiveParams(); AutomationChannel::ReleaseProof publishLiveParams();
// Publishes `play`'s three drawn contours to every sounding voice WITHOUT writing the
// model — the contour drag's courier (this directory's CLAUDE.md, the authority table).
// UI thread; serialized against reloadInstrument's own contour publish.
void publishLiveContours(const PlaySeconds& play);
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // 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 // 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. // fixed stereo, so a mode change never renegotiates host I/O.
@@ -387,6 +394,10 @@ private:
// Both live_ and draining_ observe this same block — a block owned by a snapshot would // 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. // leave the drain's still-sounding voices deaf to the knob under them.
instrument::engine::LiveParams liveParams_; instrument::engine::LiveParams liveParams_;
// The drawn-contour block, SampleData::liveContours' one target, under liveParams_' ordering
// constraint and for its reason. No automation merge: no contour is a host parameter, so both
// engines read the model/editor publish directly.
instrument::engine::LiveContourBlock liveContours_;
// The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by // The block the ENGINE reads, and the ONE thing SampleData::live points at. Written only by
// the audio thread, which merges liveParams_ with the host's automation points once per block // the audio thread, which merges liveParams_ with the host's automation points once per block
// and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane // and republishes ONLY when the RESULT moved — so neither an unchanged model nor a lane
@@ -404,10 +415,10 @@ private:
// The host lane's state and its release protocol; automation_channel.h owns the mechanism and // The host lane's state and its release protocol; automation_channel.h owns the mechanism and
// this directory's CLAUDE.md the authority model it enforces. // this directory's CLAUDE.md the authority model it enforces.
AutomationChannel automation_; AutomationChannel automation_;
// Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) // Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams) and of
// only — separate from reloadMutex_ so a knob drag's publish never blocks behind a // liveContours_ (reloadInstrument, publishLiveContours) only — separate from reloadMutex_ so
// reload's WAV decode. The audio thread never takes this; process() only reads via // a drag's publish never blocks behind a reload's WAV decode. The audio thread never takes
// LiveParams::read's lock-free seqlock retry. // this; it only reads via the seqlock's lock-free retry.
std::mutex livePublishMutex_; std::mutex livePublishMutex_;
// 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
+438
View File
@@ -0,0 +1,438 @@
// Standalone tests for LIVE CONTOUR DELIVERY — a drawn EG edited while notes sound — no VST3,
// no REAPER, no framework. Asserts that each of the three contours (amp, pitch, filter) moves
// the note already sounding and the next one struck, that the swap is absorbed rather than
// stepped, that what start() latched (a Staged EG) stays latched, that an unchanged publish is
// byte-identical, that two engines sharing one block both follow (the drain slot), and that the
// whole audio-side path allocates nothing. This executable replaces the global allocator to
// prove the last one, which is why these tests are not in live_delivery_tests.
#include "../src/core/instrument/engine/live_contours.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <vector>
using namespace reasampler;
using instrument::engine::ContourPoints;
using instrument::engine::CurveDomain;
using instrument::engine::LiveContourBlock;
using instrument::engine::contourPoints;
using instrument::engine::foldContours;
// --- Allocation counter -----------------------------------------------------------------
// Counts every global allocation while armed. Replacing the global operator new is the only way
// to observe allocations made inside sampler_core without instrumenting it.
static std::atomic<long> g_allocations{0};
static std::atomic<bool> g_countAllocations{false};
static void* countedAlloc(std::size_t n) {
if (g_countAllocations.load(std::memory_order_relaxed)) {
g_allocations.fetch_add(1, std::memory_order_relaxed);
}
if (void* p = std::malloc(n ? n : 1)) return p;
throw std::bad_alloc();
}
void* operator new(std::size_t n) { return countedAlloc(n); }
void* operator new[](std::size_t n) { return countedAlloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete[](void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
void operator delete[](void* p, std::size_t) noexcept { std::free(p); }
// ------------------------------------------------------------------------------------------
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;
constexpr std::size_t kBlock = 256;
constexpr int kRoot = 60;
// Every frame 1.0, Trigger (a drawn EG is a Trigger fact — resolvePlay's job on the real path),
// played at the root with a flat velocity curve: an output frame IS the amplitude.
static SampleData dcSample(std::size_t frames) {
SampleData s;
s.frames.assign(frames, 1.0f);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
return s;
}
static VelocityCurve flatAt(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {127.0, y}}, domain);
}
// Three points rather than two, so a publish that changes the point COUNT is what lands.
static VelocityCurve flatAt3(double y, CurveDomain domain) {
return VelocityCurve::fromPoints({{0.0, y}, {63.0, y}, {127.0, y}}, domain);
}
static double maxAbsDelta(const std::vector<AudioSample>& 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) {
worst = (std::max)(worst, std::fabs(static_cast<double>(v[i]) - v[i - 1]));
}
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;
}
// One held note through a one-voice engine observing `block`, with `edited`'s contours
// published at the top of block `swapBlock` (-1: never). The initial publish is `sample.play`'s
// own contours, the state a reload leaves.
static std::vector<AudioSample> renderHeld(SampleData& sample, LiveContourBlock& block,
const PlayParams* edited, int swapBlock, int blocks) {
sample.liveContours = &block;
block.publish(foldContours(sample.play));
VoiceEngine engine(1, sample);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < blocks; ++b) {
if (edited && b == swapBlock) block.publish(foldContours(*edited));
engine.render(out, kBlock);
}
return out;
}
// --- The fold ------------------------------------------------------------------------------
static void testTheFoldCarriesEveryPointAndItsHardFlag() {
std::vector<VelocityPoint> pts = {{0.0, 0.1}, {40.0, 0.9}, {90.0, 0.2}, {127.0, 0.7}};
VelocityCurve c = VelocityCurve::fromPoints(pts, CurveDomain::Unipolar);
c.setHard(2, true);
const ContourPoints folded = contourPoints(c);
CHECK(folded.count == 4);
for (std::size_t i = 0; i < 4; ++i) {
CHECK(folded.points[i].velocity == c.points()[i].velocity);
CHECK(folded.points[i].value == c.points()[i].value);
CHECK(folded.points[i].hard == c.points()[i].hard);
}
CHECK(folded.points[2].hard);
PlayParams p;
p.ampSpline.contour = flatAt(0.3, CurveDomain::Unipolar);
p.pitchSpline.contour = c;
p.filterSpline.contour = flatAt3(0.6, CurveDomain::Unipolar);
const auto all = foldContours(p);
CHECK(all.amp.count == 2 && all.amp.points[1].value == 0.3);
CHECK(all.pitch.count == 4 && all.pitch.points[1].value == c.points()[1].value);
CHECK(all.filter.count == 3 && all.filter.points[2].value == 0.6);
}
// --- Amp -----------------------------------------------------------------------------------
static void testAHeldNoteFollowsAnAmpContourEditWithoutAStep() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
CHECK(moved[swapAt - 1] == 1.0f);
// Absorbed: the first frame read through the new contour reproduces the old level exactly...
CHECK(moved[swapAt] == 1.0f);
// ...and glides from there. An unabsorbed swap would step by 0.75 in one frame.
CHECK(maxAbsDelta(moved, swapAt - 1, swapAt + 64) < 0.05);
CHECK(moved[swapAt + 32] < 0.9f);
// The held note lands on the edited contour, where the unedited run stays on the old one.
CHECK(std::fabs(moved.back() - 0.25) < 1e-6);
CHECK(still.back() == 1.0f);
}
// The processor marshals note-ons BEFORE the block renders, so a publish and a note-on landing
// in one block reach the engine through startVoice first. The voice already sounding must be
// rebound there too: left on the buffer the read just displaced, it would never see this edit.
static void testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(2, s);
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
for (int b = 0; b < 4; ++b) engine.render(out, kBlock);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127); // the second voice, struck in the swap block
for (int b = 0; b < 8; ++b) engine.render(out, kBlock);
const std::size_t swapAt = 4 * kBlock;
// The sounding voice's absorbed 1.0 plus the fresh voice's snapped 0.25 — the fresh one
// takes the edit outright even though the snapshot still carries the old contour.
CHECK(std::fabs(out[swapAt] - 1.25) < 1e-6);
// Both land on the edit: 0.25 + 0.25. A sounding voice that missed it would leave 1.25.
CHECK(std::fabs(out.back() - 0.5) < 1e-6);
}
static void testANoteStruckAfterAnEditPlaysItFromItsFirstFrame() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(1, s);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
engine.noteOn(kRoot, 127);
std::vector<AudioSample> out;
engine.render(out, kBlock);
CHECK(out[0] == 0.25f);
// The snapshot really does still say 1.0: with no block the same engine plays it.
SampleData bare = s;
bare.liveContours = nullptr;
VoiceEngine bareEngine(1, bare);
bareEngine.noteOn(kRoot, 127);
std::vector<AudioSample> bareOut;
bareEngine.render(bareOut, kBlock);
CHECK(bareOut[0] == 1.0f);
}
// --- Pitch ---------------------------------------------------------------------------------
// A ramp source under Varispeed: an output frame is the read head, so the frame-to-frame step
// IS the pitch ratio.
static void testAHeldNoteFollowsAPitchContourEdit() {
SampleData s;
s.frames.resize(1u << 15);
for (std::size_t i = 0; i < s.frames.size(); ++i) s.frames[i] = static_cast<float>(i);
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
PlayParams edited = s.play;
edited.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar); // +1 x 12 st = an octave
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 12);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 12);
const std::size_t swapAt = 4 * kBlock;
const std::size_t n = moved.size();
CHECK(std::fabs((moved[swapAt - 1] - moved[swapAt - 2]) - 1.0) < 1e-3);
CHECK(std::fabs((moved[n - 1] - moved[n - 2]) - 2.0) < 1e-3);
CHECK(std::fabs((still[n - 1] - still[n - 2]) - 1.0) < 1e-3);
// Absorbed here too: the first frame read through the new contour still advances at the old
// ratio, where an unabsorbed swap would already step by 2.
CHECK(std::fabs((moved[swapAt + 1] - moved[swapAt]) - 1.0) < 1e-3);
}
// --- Filter --------------------------------------------------------------------------------
static void testAHeldNoteFollowsAFilterContourEdit() {
SampleData s;
s.frames.resize(100000);
for (std::size_t i = 0; i < s.frames.size(); ++i) {
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * static_cast<double>(i) / 8.0));
}
s.sampleRate = kRate;
s.rootNote = kRoot;
s.play.playMode = PlayMode::Trigger;
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 0.1f;
s.play.filter.settings.morphNorm = 1.0f; // low-pass
s.play.filter.modAmount = 0.8;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar); // corner 0.1 -> 0.9
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> moved = renderHeld(s, blockA, &edited, 4, 16);
const std::vector<AudioSample> still = renderHeld(base, blockB, nullptr, -1, 16);
const std::size_t tail = moved.size() - 1024;
const double openedPeak = peakOf(moved, tail, moved.size());
const double closedPeak = peakOf(still, tail, still.size());
// A 6 kHz tone through the corner the edit opened, against the unedited note's, which stays
// filtered away (measured ~0.18 vs ~0.0002 — the margins below are loose on purpose).
CHECK(openedPeak > 0.1);
CHECK(openedPeak > 10.0 * closedPeak);
}
// --- What stays latched, and what must not move --------------------------------------------
// Staged|Spline is a note-on fact: an EG that start() left Staged ignores any contour publish.
static void testAStagedEnvelopeIgnoresContourPublishes() {
SampleData s = dcSample(200000);
// A staged shape that is not flat from frame 0, so a leak would show.
s.play.trigAhd.decayFrames = 20000;
s.play.trigAhd.holdFraction = 0.0;
PlayParams hostile = s.play;
hostile.ampSpline.contour = flatAt3(0.0, CurveDomain::Unipolar);
hostile.pitchSpline.contour = flatAt3(1.0, CurveDomain::Bipolar);
hostile.filterSpline.contour = flatAt3(1.0, CurveDomain::Unipolar);
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> published = renderHeld(s, blockA, &hostile, 3, 10);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 10);
CHECK(published == none);
CHECK(none[3 * kBlock + 100] < 1.0f); // the staged decay is really running
}
// Republishing the contour a voice is already reading rebinds it mid-read; the read must not
// move by a bit. A curved, multi-segment contour with a hard point so the re-located segment
// and its tangents are genuinely exercised.
static void testRepublishingAnUnchangedContourIsByteIdentical() {
SampleData s = dcSample(20000);
s.play.ampSpline.mode = EnvMode::Spline;
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.0}, {20.0, 1.0}, {60.0, 0.3}, {100.0, 0.9}, {127.0, 0.0}},
CurveDomain::Unipolar);
c.setHard(2, true);
s.play.ampSpline.contour = c;
const PlayParams same = s.play;
SampleData base = s;
LiveContourBlock blockA, blockB;
const std::vector<AudioSample> republished = renderHeld(s, blockA, &same, 20, 60);
const std::vector<AudioSample> none = renderHeld(base, blockB, nullptr, -1, 60);
CHECK(republished == none);
CHECK(peakOf(none, 0, none.size()) > 0.9); // the contour really is being read
}
// The drain slot: a displaced snapshot and the live one both observe the instance's ONE block,
// so a note ringing out of either follows the edit.
static void testTwoEnginesSharingOneBlockBothFollow() {
SampleData a = dcSample(200000);
a.play.ampSpline.mode = EnvMode::Spline;
a.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
SampleData b = a;
LiveContourBlock block;
a.liveContours = &block;
b.liveContours = &block;
block.publish(foldContours(a.play));
VoiceEngine live(1, a), drain(1, b);
live.noteOn(kRoot, 127);
drain.noteOn(kRoot, 127);
std::vector<AudioSample> outLive, outDrain;
for (int i = 0; i < 2; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
PlayParams edited = a.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
block.publish(foldContours(edited));
for (int i = 0; i < 8; ++i) { live.render(outLive, kBlock); drain.render(outDrain, kBlock); }
CHECK(std::fabs(outLive.back() - 0.25) < 1e-6);
CHECK(std::fabs(outDrain.back() - 0.25) < 1e-6);
}
// --- Allocation ----------------------------------------------------------------------------
// The counter itself must be live, or the zero below would prove nothing.
static void testTheAllocationCounterSeesAnAllocation() {
g_allocations.store(0);
g_countAllocations.store(true);
std::vector<float>* v = new std::vector<float>(16);
g_countAllocations.store(false);
delete v;
CHECK(g_allocations.load() >= 2); // the vector object and its buffer
}
// Every audio-side step of a contour swap — the block-boundary read, the rebind of every
// sounding voice on all three EGs, a note struck in the swap block, and the renders around them
// — runs with the counter armed. Only the UI thread's publish is outside it (it allocates
// nothing either, but it is not the audio side).
static void testTheAudioSideOfAContourSwapAllocatesNothing() {
SampleData s = dcSample(200000);
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = flatAt(1.0, CurveDomain::Unipolar);
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 0.0; // bound, so rebound, but transposing nothing
s.play.pitchSpline.mode = EnvMode::Spline;
s.play.pitchSpline.contour = flatAt(0.0, CurveDomain::Bipolar);
s.play.filter.enabled = true;
s.play.filter.settings.cutoffNorm = 1.0f;
s.play.filter.modAmount = 0.0;
s.play.filterSpline.mode = EnvMode::Spline;
s.play.filterSpline.contour = flatAt(0.0, CurveDomain::Unipolar);
PlayParams edited = s.play;
edited.ampSpline.contour = flatAt3(0.25, CurveDomain::Unipolar);
edited.pitchSpline.contour = flatAt3(0.0, CurveDomain::Bipolar);
edited.filterSpline.contour = flatAt3(0.5, CurveDomain::Unipolar);
LiveContourBlock block;
s.liveContours = &block;
block.publish(foldContours(s.play));
VoiceEngine engine(4, s);
std::vector<AudioSample> buf(kBlock, 0.0f);
const auto renderBlock = [&] {
std::fill(buf.begin(), buf.end(), 0.0f);
engine.render(buf.data(), kBlock);
};
g_allocations.store(0);
g_countAllocations.store(true);
engine.noteOn(kRoot, 127);
for (int b = 0; b < 4; ++b) renderBlock();
g_countAllocations.store(false);
block.publish(foldContours(edited)); // UI thread
g_countAllocations.store(true);
engine.noteOn(kRoot, 127); // startVoice's rebind + snap
renderBlock(); // the block boundary's read finds nothing newer: already taken
g_countAllocations.store(false);
const double afterEdit = buf[kBlock - 1];
block.publish(foldContours(s.play)); // and back, so the render path does the read itself
g_countAllocations.store(true);
for (int b = 0; b < 8; ++b) renderBlock();
g_countAllocations.store(false);
CHECK(g_allocations.load() == 0);
// Not vacuous: both swaps really landed inside the counted window, through a wide-open
// low-pass whose DC gain is unity. Two voices on the edited 0.25 contour by the end of the
// swap block (2.0 had it not landed), then both back on 1.0 through the render path's read.
CHECK(std::fabs(afterEdit - 0.5) < 1e-3);
CHECK(std::fabs(buf[kBlock - 1] - 2.0) < 1e-3);
}
int main() {
testTheFoldCarriesEveryPointAndItsHardFlag();
testAHeldNoteFollowsAnAmpContourEditWithoutAStep();
testANoteOnInTheSwapBlockRebindsTheSoundingVoiceAndSnapsTheNewOne();
testANoteStruckAfterAnEditPlaysItFromItsFirstFrame();
testAHeldNoteFollowsAPitchContourEdit();
testAHeldNoteFollowsAFilterContourEdit();
testAStagedEnvelopeIgnoresContourPublishes();
testRepublishingAnUnchangedContourIsByteIdentical();
testTwoEnginesSharingOneBlockBothFollow();
testTheAllocationCounterSeesAnAllocation();
testTheAudioSideOfAContourSwapAllocatesNothing();
if (g_fail == 0) std::printf("live_contours tests passed\n");
return g_fail == 0 ? 0 : 1;
}