diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 629b23d..f16dd02 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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 voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by 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) @@ -255,8 +263,9 @@ consequences, each with one home: - **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. - **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 - (`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it + structs.** Those are copied whole into the live block, which must stay small and trivially + 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. **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: - `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. - - `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_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. @@ -396,7 +405,7 @@ anything for a trigger shape. envelope that was never running. - **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by - `snapLive` before its first frame. + `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 covers client-pixel widths (including multiples standing in for larger client areas); nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 9c917b0..e44d042 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -26,10 +26,11 @@ add_subdirectory(filter) # marker layer. After filter: it links play_params' dependency set, which includes it. add_subdirectory(loop) -# The live-parameter block: the value layer plus its publication, deliberately linking no -# engine — the block is a plain value the voice observes, not a thing the engine owns. +# The live-parameter block and the drawn-contour block beside it: value layers plus their +# 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 - SOURCES live_params.cpp + SOURCES live_params.cpp live_contours.cpp LINK PUBLIC peaks velocity_curve filter curve_law) 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. 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 # both mode shapes share, and the Trigger tail's terminal behaviour. reasampler_test(staged_envelopes LINK sampler_core) diff --git a/src/core/instrument/engine/live_contours.cpp b/src/core/instrument/engine/live_contours.cpp new file mode 100644 index 0000000..19542dc --- /dev/null +++ b/src/core/instrument/engine/live_contours.cpp @@ -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& 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(n); + return out; +} + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/live_contours.h b/src/core/instrument/engine/live_contours.h new file mode 100644 index 0000000..5db2fed --- /dev/null +++ b/src/core/instrument/engine/live_contours.h @@ -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 +#include + +#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, + "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 +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 {}; + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/live_params.h b/src/core/instrument/engine/live_params.h index 6b12332..b36fdd1 100644 --- a/src/core/instrument/engine/live_params.h +++ b/src/core/instrument/engine/live_params.h @@ -1,15 +1,15 @@ #pragma once // live_params.h — the live playback-parameter block: the plain value bundle the audio thread -// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a -// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the +// observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn +// read, the ONE fold from PlayParams that keeps the two representations in step, and the // per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every // instrument snapshot (see SampleData::live). -#include #include #include #include "core/instrument/engine/play_params.h" +#include "core/instrument/engine/seqlock.h" 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. LiveValues foldLive(const PlayParams& params, double keyTrack); -// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd -// and an even generation; the reader copies the block and re-checks the generation, retrying -// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the -// reader: after the retry budget it reports "nothing new" and the caller keeps its last good -// snapshot rather than spinning on the audio thread. -// -// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the -// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the -// block, and a reader then accepts a torn block as coherent. Every publisher must serialize. -// -// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock: -// the fences give correct ordering, but the concurrent read of a non-atomic object is a data -// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not -// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain -// value the audio thread can copy in one shot. -// -// The writer interface deliberately assumes NO particular thread beyond single-writer, so a -// host's own parameter-change queue (delivered on the audio thread with sample offsets) can -// drive it later without a redesign. -class LiveParams { -public: - // A generation of 0 means "never published"; the first publish lands on 2. - void publish(const LiveValues& values) { - const std::uint32_t gen = seq_.load(std::memory_order_relaxed); - seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress - std::atomic_thread_fence(std::memory_order_release); - values_ = values; - std::atomic_thread_fence(std::memory_order_release); - // Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and - // stall every reader until the NEXT publish — a silent mode, unlike a loud one. - const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2; - seq_.store(next, std::memory_order_release); // even: complete and coherent - } - - // 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 seq_{0}; - LiveValues values_{}; -}; +// The live-parameter block's publication — seqlock.h owns the protocol and its single-writer +// contract. A class rather than an alias so play_params.h can forward-declare it. +class LiveParams : public Seqlock {}; // 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, diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index c4229e9..7393241 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -15,7 +15,8 @@ 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 instrument::engine::VelocityCurve; @@ -181,8 +182,8 @@ struct PlayParams { // 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 // 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 - // curves it travels by reload. + // copyable (live_params.h). A contour reaches a sounding voice through its own block + // instead (live_contours.h); only its Staged|Spline mode travels by reload. SplineEnv ampSpline; SplineEnv pitchSpline; 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 // hearing. Do not "fix" it by moving ownership into the snapshot. 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 // never half-plays. diff --git a/src/core/instrument/engine/seqlock.h b/src/core/instrument/engine/seqlock.h new file mode 100644 index 0000000..cb36215 --- /dev/null +++ b/src/core/instrument/engine/seqlock.h @@ -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 +#include +#include + +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 Seqlock { + static_assert(std::is_trivially_copyable_v, + "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 seq_{0}; + T values_{}; +}; + +} // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index 34532ec..dc1bb75 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -224,17 +224,20 @@ private: // sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the // 128-point ceiling. // -// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve -// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime. +// Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the +// 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 { public: // 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. - void bind(const VelocityCurve& c) { - const std::vector& pts = c.points(); - if (pts.size() < 2) { clear(); return; } - pts_ = pts.data(); - n_ = pts.size(); + void bind(const VelocityCurve& c) { bind(c.points().data(), c.points().size()); } + // The same over a raw X-ordered array (live_contours.h's fixed-capacity copy). Rebinding a + // cursor mid-read is legal: it restarts the segment cache, and the next eval re-locates. + void bind(const VelocityPoint* pts, std::size_t n) { + if (pts == nullptr || n < 2) { clear(); return; } + pts_ = pts; + n_ = n; select(0); } void clear() { pts_ = nullptr; n_ = 0; } diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index a8b4e11..ded44cc 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -109,6 +109,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick } else { filterSplineCur_.clear(); } + ampSplineSmooth_.clear(); + pitchSplineSmooth_.clear(); + filterSplineSmooth_.clear(); // 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. @@ -330,6 +333,29 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) { 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) { // Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps // running (no re-attack), the read head keeps its position, the shifter keeps its ring diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 7a135e6..6e225c1 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -15,6 +15,7 @@ #include "core/instrument/engine/envelopes.h" #include "core/instrument/engine/filter/filter_params.h" #include "core/instrument/engine/filter/voice_filter.h" +#include "core/instrument/engine/live_contours.h" #include "core/instrument/engine/live_params.h" #include "core/instrument/engine/loop/loop_span.h" #include "core/instrument/engine/pitch_shift.h" @@ -179,6 +180,14 @@ public: // pitch ratio, and the decoded PCM stay latched at note-on. 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 // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which // runs inside process() — never allocates. <= 1 leaves the shifters pass-through. @@ -268,6 +277,14 @@ private: // its opening value. 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 // 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 @@ -284,7 +301,7 @@ private: if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) { // 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. - amp = ampSplineCur_.eval(splinePhase()); + amp = readSpline(ampSplineCur_, ampSplineSmooth_); if (amp == 0.0 && ampSplineCur_.onFinalSegment() && ampSplineCur_.segmentEndValue() == 0.0) { amplitudeDone_ = true; @@ -321,7 +338,7 @@ private: // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the // branch is perfectly predicted. const double envOut = filterSplineCur_.active() - ? filterSplineCur_.eval(splinePhase()) + ? readSpline(filterSplineCur_, filterSplineSmooth_) : ((playMode_ == PlayMode::Gate) ? filterEnv_.tick() : filterAhd_.amplitudeAt(sourceOffset())); @@ -514,9 +531,10 @@ private: seedTerminalDeclick(); } const double gain = amp * velocityGain_; - const double pitchEnvSemis = pitchSplineCur_.active() - ? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase()) - : pitchEnv_.tick(); + const double pitchEnvSemis = + pitchSplineCur_.active() + ? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_) + : pitchEnv_.tick(); // 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. @@ -748,13 +766,18 @@ private: 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 - // SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor - // inactive, so a purely staged instrument's per-sample path gains three predicted 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. + // SampleData outlives the voice — same contract as sample_) and, when the engine observes a + // contour block, re-pointed at its engine's live copy (applyContours). A Staged EG leaves its + // cursor inactive, so a purely staged instrument's per-sample path gains three predicted + // 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 pitchSplineCur_; SplineCursor filterSplineCur_; + StepSmoother ampSplineSmooth_; + StepSmoother pitchSplineSmooth_; + StepSmoother filterSplineSmooth_; double splineScale_ = 0.0; double pitchSplineDepth_ = 0.0; diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp index f7ab3cf..eed4e4c 100644 --- a/src/core/instrument/engine/voice_engine.cpp +++ b/src/core/instrument/engine/voice_engine.cpp @@ -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) { + // FIRST, so no sounding voice is left bound to a buffer the next read overwrites. + applyContoursToActive(); refreshLive(); // 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 @@ -65,6 +83,8 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) { voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack, lengthFraction); 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_++); } @@ -258,6 +278,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) { // audio thread never touches the heap. if (out == nullptr || frameCount == 0) return; applyLiveToActive(); // block boundary, once — never inside the frame loop + applyContoursToActive(); for (Voice& voice : voices_) { if (!voice.active()) continue; 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). if (left == nullptr || right == nullptr || frameCount == 0) return; applyLiveToActive(); // block boundary, once — never inside the frame loop + applyContoursToActive(); for (Voice& voice : voices_) { if (!voice.active()) continue; for (std::size_t f = 0; f < frameCount; ++f) { diff --git a/src/core/instrument/engine/voice_engine.h b/src/core/instrument/engine/voice_engine.h index e06995a..f437926 100644 --- a/src/core/instrument/engine/voice_engine.h +++ b/src/core/instrument/engine/voice_engine.h @@ -10,6 +10,7 @@ #include #include "core/audio/peaks.h" +#include "core/instrument/engine/live_contours.h" #include "core/instrument/engine/live_params.h" #include "core/instrument/engine/play_params.h" #include "core/instrument/engine/voice.h" @@ -57,6 +58,11 @@ public: MonoTrigger monoTrigger = MonoTrigger::Retrigger, 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 // 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. @@ -124,6 +130,22 @@ private: std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet 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 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 // (cheap: bounded by maxVoices) rather than maintained as a running tally. std::size_t activePreserveVoices() const; diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index d21cd82..1e34d86 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -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 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 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. @@ -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 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 - 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 @@ -123,6 +134,7 @@ the single authority.** Everything else that holds these values is a cache or a | Writer | Authority begins | Authority ends | |---|---|---| | 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) | | State restore (`setState`) | 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 | | **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" -is just "the write happened". Reload seed is not itself a model write — `reloadInstrument` never -touches `params_`; the only write in the tree is `setInstrumentParams`'s, `processor_state.cpp:193` +Every writer above except the contour courier and the last two writes the model directly, so for +those "authority ends" is just "the write happened". Reload seed is not itself a model write — +`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 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 diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index 27332cb..6eb0d3c 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -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 // rather than letting it fall through to a marker grab under the cursor. if (index < 0) return true; + publishContoursLive(); // the new point is heard before the first move, not after it } drag_ = DragKind::kSplineNode; curvePointIndex_ = index; @@ -188,7 +189,7 @@ bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, in dragCurveRect_ = waveArea.rect; dragStartX_ = x; dragStartY_ = y; - invalidate(); // live feedback; the commit lands on WM_LBUTTONUP + invalidate(); // the model commit lands on WM_LBUTTONUP return true; } @@ -235,7 +236,8 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) { splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag( dragStartCurve_, static_cast(curvePointIndex_), splineOverlayBox(overlay), dx, y - dragStartY_); - invalidate(); // live feedback; commit on release + publishContoursLive(); // heard now; the model write stays the release's commit + invalidate(); return; } diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index d329fe5..5631266 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -312,6 +312,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, // drag used. if (self->dragCommitsLive(self->drag_, self->dragParamId_)) { 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 diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 2b82324..984cf5c 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -251,6 +251,10 @@ void ReaSamplerEditor::commitLive() { processor_->publishLiveParams(); } +void ReaSamplerEditor::publishContoursLive() { + if (processor_) processor_->publishLiveContours(params_.play); +} + bool ReaSamplerEditor::deckKnobIsProcessorSide(int paramId) { return paramId == -2 || paramId == static_cast(ParamControl::kVoiceCount) || diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 5c32d62..e4306de 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -153,6 +153,7 @@ std::string ReaSamplerProcessor::reloadInstrument() { // 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. sample.live = &automationLive_; + sample.liveContours = &liveContours_; // 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, // 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. std::lock_guard lp(livePublishMutex_); 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 } diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index c33fa27..b562c70 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -293,6 +293,14 @@ AutomationChannel::ReleaseProof ReaSamplerProcessor::publishLiveParams() { 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 lock(livePublishMutex_); + liveContours_.publish(contours); +} + SampleRefs ReaSamplerProcessor::sampleRefs() { std::lock_guard lock(refsMutex_); return sampleRefs_; diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 8750798..1302581 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -267,6 +267,12 @@ private: // the knob. Does not repaint; callers already do. UI thread only. 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 // live per deckParamCommit; an envelope-node drag is live in EITHER mode — see // liveCommitFor (deck_groups.h) for why. diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 8b0f177..71361ae 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -23,6 +23,7 @@ #include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set) #include "core/instrument/map/component_state_io.h" // ComponentState codec #include "core/instrument/engine/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/meter_accumulate.h" // the meter's block-rate folds + consume #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::InstrumentParams; +using instrument::map::PlaySeconds; using instrument::map::SampleRefEntry; using instrument::map::SampleRefs; 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. 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 // 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. @@ -387,6 +394,10 @@ private: // Both live_ and draining_ observe this same block — a block owned by a snapshot would // leave the drain's still-sounding voices deaf to the knob under them. instrument::engine::LiveParams liveParams_; + // 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 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 @@ -404,10 +415,10 @@ private: // 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. AutomationChannel automation_; - // Serializes liveParams_.publish's two writer sites (reloadInstrument, publishLiveParams) - // only — separate from reloadMutex_ so a knob drag's publish never blocks behind a - // reload's WAV decode. The audio thread never takes this; process() only reads via - // LiveParams::read's lock-free seqlock retry. + // Serializes the writer sites of liveParams_ (reloadInstrument, publishLiveParams) and of + // liveContours_ (reloadInstrument, publishLiveContours) only — separate from reloadMutex_ so + // a drag's publish never blocks behind a reload's WAV decode. The audio thread never takes + // this; it only reads via the seqlock's lock-free retry. std::mutex livePublishMutex_; // The rate the loaded capture was decoded/built at, so a live republish resolves the // stored wall-clock seconds to exactly the frames the built SampleData carries. 0 = nothing diff --git a/tests/test_live_contours.cpp b/tests/test_live_contours.cpp new file mode 100644 index 0000000..e0717f6 --- /dev/null +++ b/tests/test_live_contours.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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 g_allocations{0}; +static std::atomic 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& 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(v[i]) - v[i - 1])); + } + return worst; +} + +static double peakOf(const std::vector& v, std::size_t from, std::size_t to) { + double peak = 0.0; + for (std::size_t i = from; i < to && i < v.size(); ++i) { + peak = (std::max)(peak, std::fabs(static_cast(v[i]))); + } + return peak; +} + +// 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 renderHeld(SampleData& sample, LiveContourBlock& block, + const PlayParams* edited, int swapBlock, int blocks) { + sample.liveContours = █ + block.publish(foldContours(sample.play)); + VoiceEngine engine(1, sample); + engine.noteOn(kRoot, 127); + std::vector 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 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 moved = renderHeld(s, blockA, &edited, 4, 12); + const std::vector 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.publish(foldContours(s.play)); + + VoiceEngine engine(2, s); + engine.noteOn(kRoot, 127); + std::vector 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.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 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 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(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 moved = renderHeld(s, blockA, &edited, 4, 12); + const std::vector 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(std::sin(2.0 * kPi * static_cast(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 moved = renderHeld(s, blockA, &edited, 4, 16); + const std::vector 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 published = renderHeld(s, blockA, &hostile, 3, 10); + const std::vector 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 republished = renderHeld(s, blockA, &same, 20, 60); + const std::vector 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 = █ + b.liveContours = █ + block.publish(foldContours(a.play)); + + VoiceEngine live(1, a), drain(1, b); + live.noteOn(kRoot, 127); + drain.noteOn(kRoot, 127); + std::vector 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* v = new std::vector(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.publish(foldContours(s.play)); + VoiceEngine engine(4, s); + std::vector 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; +}