Merge live spline-envelope drags: held and new notes follow a dragged knot
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
|
|||||||
// depend on what the user happened to be dragging. The dialed values are already in
|
// depend on what the user happened to be dragging. The dialed values are already in
|
||||||
// this SampleData's own play params, which is what the bake is meant to print.
|
// this SampleData's own play params, which is what the bake is meant to print.
|
||||||
sample.live = nullptr;
|
sample.live = nullptr;
|
||||||
|
sample.liveContours = nullptr;
|
||||||
|
|
||||||
const int channels = sample.channelCount();
|
const int channels = sample.channelCount();
|
||||||
// The limiter delays its output by its lookahead, so the buffers carry that many extra
|
// The limiter delays its output by its lookahead, so the buffers carry that many extra
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -224,17 +224,21 @@ 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 (that lifetime), or
|
||||||
|
// against its engine's live contour copy — the double-buffer contract VoiceEngine::contours_
|
||||||
|
// owns (voice_engine.h) is what makes that second binding safe.
|
||||||
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; }
|
||||||
|
|||||||
@@ -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 — still
|
||||||
|
// intact, by the double-buffer contract VoiceEngine::contours_ owns (voice_engine.h).
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,11 +301,17 @@ 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_);
|
||||||
|
// A swap-step absorbed by ampSplineSmooth_ can briefly push the sum outside [0,1]
|
||||||
|
// (a contour's own points are drawn in-range, but the smoother's addend isn't); the
|
||||||
|
// filter clamps its own read for the same reason (below). The finished-check reads
|
||||||
|
// the PRE-clamp value so a genuine zero-terminus isn't masked by the clamp itself.
|
||||||
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
|
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
|
||||||
ampSplineCur_.segmentEndValue() == 0.0) {
|
ampSplineCur_.segmentEndValue() == 0.0) {
|
||||||
amplitudeDone_ = true;
|
amplitudeDone_ = true;
|
||||||
}
|
}
|
||||||
|
if (amp < 0.0) amp = 0.0;
|
||||||
|
else if (amp > 1.0) amp = 1.0;
|
||||||
} else if (playMode_ == PlayMode::Gate) {
|
} else if (playMode_ == PlayMode::Gate) {
|
||||||
amp = env_.tick();
|
amp = env_.tick();
|
||||||
if (env_.finished()) amplitudeDone_ = true;
|
if (env_.finished()) amplitudeDone_ = true;
|
||||||
@@ -321,7 +344,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,8 +537,9 @@ 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()
|
||||||
|
? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_)
|
||||||
: pitchEnv_.tick();
|
: 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
|
||||||
@@ -748,13 +772,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,8 +52,30 @@ 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) {
|
||||||
refreshLive();
|
// FIRST, so no sounding voice is left bound to a buffer the next read overwrites. A held
|
||||||
|
// note sharing this generation with the note-on must see it too — refreshLive() alone
|
||||||
|
// would consume the generation without applying it, and since applyLiveToActive() only
|
||||||
|
// acts on a NEW generation, the held voice would then never see this publish at all
|
||||||
|
// (not merely late a block), only catching up whenever the next one arrives.
|
||||||
|
applyContoursToActive();
|
||||||
|
applyLiveToActive();
|
||||||
// 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
|
||||||
// touches none of these three — so a move reaches the next note and no sounding one.
|
// touches none of these three — so a move reaches the next note and no sounding one.
|
||||||
@@ -65,6 +87,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 +282,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 +299,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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ 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 and the
|
||||||
|
editor closing mid-drag (`removedFromParent`) are the two exits that commit nothing — both
|
||||||
|
re-courier 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 +120,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 +135,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 +143,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
|
||||||
@@ -229,13 +242,19 @@ against a performance budget — they are there because `VoiceEngine::applyLiveT
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **`reasampler_processor.h` no longer needs a ceiling exception, and the one it had rested on a
|
- **`reasampler_processor.h` carries a small, documented ceiling exception.** It was described as
|
||||||
false premise.** It was described as ONE class declaration; it also carried two namespace-scope
|
ONE class declaration; it also carried two namespace-scope aggregates (`MasterBusMeter`,
|
||||||
aggregates (`MasterBusMeter`, `LoadedInstrument`) and the automation lane's own state. Both are
|
`LoadedInstrument`) and the automation lane's own state. All three are split out —
|
||||||
now split out — `processor_snapshot.h` and `automation_channel.h`, on the same grounds
|
`processor_snapshot.h` and `automation_channel.h`, on the same grounds `editor_interaction.h`
|
||||||
`editor_interaction.h` was split out of `reasampler_editor.h` in this directory: neither is
|
was split out of `reasampler_editor.h` in this directory: neither is behaviour. What remains is
|
||||||
behaviour. What remains is under the ceiling. Its bulk is the drain-slot proof and the
|
the class declaration itself, a handful of lines over the ceiling since the live-contour block
|
||||||
RT-discipline constraints, which the comment conventions name as keep-worthy.
|
(`liveContours_` + `publishLiveContours`) landed beside `liveParams_` under the same ordering
|
||||||
|
constraint. No further seam exists that isn't behaviour: every remaining aggregate-shaped chunk
|
||||||
|
(the preview mailbox, the drain-slot triple) is read and written inline by `process()` on the
|
||||||
|
audio thread, so splitting it into its own header would either duplicate the RT-discipline
|
||||||
|
comments at a second site or turn a plain member access into a cross-header one for no gain.
|
||||||
|
Its bulk is the drain-slot proof and the RT-discipline constraints, which the comment
|
||||||
|
conventions name as keep-worthy.
|
||||||
|
|
||||||
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
|
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
|
||||||
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
|
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ void ReaSamplerEditor::removedFromParent() {
|
|||||||
// an edit forever and every later internal write to that parameter would emit a bare
|
// an edit forever and every later internal write to that parameter would emit a bare
|
||||||
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
// performEdit. The capture-lost path normally closes it; this does not rely on Windows
|
||||||
// delivering WM_CAPTURECHANGED before the window goes away.
|
// delivering WM_CAPTURECHANGED before the window goes away.
|
||||||
|
if (drag_ == DragKind::kSplineNode) {
|
||||||
|
// A contour drag reaches the voices without the model (see publishContoursLive) — the
|
||||||
|
// window closing mid-drag is a second exit alongside WM_CAPTURECHANGED that must roll
|
||||||
|
// the voices back the same way, or an abandoned drag stays audible after the editor
|
||||||
|
// that started it is gone.
|
||||||
|
params_ = dragStartParams_;
|
||||||
|
publishContoursLive();
|
||||||
|
drag_ = DragKind::kNone;
|
||||||
|
}
|
||||||
if (processor_) processor_->endParamGesture();
|
if (processor_) processor_->endParamGesture();
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
|
||||||
@@ -312,6 +321,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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,9 @@ 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_;
|
||||||
|
// SampleData::liveContours' one target, under liveParams_' ordering constraint above and for
|
||||||
|
// its reason (this directory's CLAUDE.md owns why it needs no automation merge).
|
||||||
|
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 +414,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "../src/core/instrument/bake/bake_render.h"
|
#include "../src/core/instrument/bake/bake_render.h"
|
||||||
|
|
||||||
#include "../src/core/instrument/engine/limiter.h"
|
#include "../src/core/instrument/engine/limiter.h"
|
||||||
|
#include "../src/core/instrument/engine/live_contours.h"
|
||||||
#include "../src/core/instrument/engine/live_params.h"
|
#include "../src/core/instrument/engine/live_params.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -338,6 +339,36 @@ int main() {
|
|||||||
CHECK(sameSamples(audio, reference));
|
CHECK(sameSamples(audio, reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The bake detaches liveContours too, not just live -------------------------------
|
||||||
|
// A contour drag in flight publishes into liveContours (see reasampler_processor.h); a
|
||||||
|
// render that observed it would print whatever the user happens to be dragging instead
|
||||||
|
// of the dialed shape already sitting in play.ampSpline.contour.
|
||||||
|
{
|
||||||
|
instrument::engine::LiveContourBlock block;
|
||||||
|
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
|
||||||
|
s.play.playMode = PlayMode::Trigger;
|
||||||
|
s.play.ampSpline.mode = EnvMode::Spline;
|
||||||
|
s.play.ampSpline.contour = instrument::engine::VelocityCurve::flat(); // dialed: unity
|
||||||
|
|
||||||
|
// Publish a very different contour — ramping down to silence — into the block.
|
||||||
|
s.liveContours = █
|
||||||
|
{
|
||||||
|
PlayParams dragged = s.play;
|
||||||
|
dragged.ampSpline.contour = instrument::engine::VelocityCurve::rampDown();
|
||||||
|
block.publish(instrument::engine::foldContours(dragged));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
||||||
|
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
|
||||||
|
|
||||||
|
CHECK(s.liveContours == &block); // the caller's own snapshot was not detached
|
||||||
|
|
||||||
|
SampleData detached = s;
|
||||||
|
detached.liveContours = nullptr;
|
||||||
|
const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter);
|
||||||
|
CHECK(sameSamples(audio, reference));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Regression baseline: the neutral render is the source, sample for sample --------
|
// --- Regression baseline: the neutral render is the source, sample for sample --------
|
||||||
// A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no
|
// A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no
|
||||||
// filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at
|
// filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at
|
||||||
|
|||||||
@@ -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.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.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.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 = █
|
||||||
|
b.liveContours = █
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
@@ -1167,6 +1167,38 @@ static void testVelocityGainSurvivesAHostilePublishThatReallyLands() {
|
|||||||
CHECK(proportional);
|
CHECK(proportional);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A note-on that lands in the same block boundary as a publish must not let the already-
|
||||||
|
// sounding voice miss it: startVoice used to call refreshLive() bare, which consumes the
|
||||||
|
// generation stamp without applying it to the voices already active — a held note that shared
|
||||||
|
// a block with a note-on then never saw the publish at all (not merely delayed a block).
|
||||||
|
static void testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn() {
|
||||||
|
SampleData s;
|
||||||
|
s.frames.assign(200000, 1.0f); // DC: the envelope alone shapes the output
|
||||||
|
s.sampleRate = kRate;
|
||||||
|
s.rootNote = 60;
|
||||||
|
s.play.adsr.attackFrames = 0;
|
||||||
|
s.play.adsr.sustainLevel = 1.0;
|
||||||
|
s.play.adsr.releaseFrames = 480000; // long enough that release never intrudes
|
||||||
|
|
||||||
|
LiveParams block;
|
||||||
|
s.live = █
|
||||||
|
VoiceEngine engine(2, s);
|
||||||
|
engine.noteOn(60, 100); // voice A, sustaining at the dialed 1.0
|
||||||
|
|
||||||
|
LiveValues quieter = foldLive(s.play, s.keyTrack);
|
||||||
|
quieter.adsr.sustainLevel = 0.2;
|
||||||
|
block.publish(quieter);
|
||||||
|
|
||||||
|
engine.noteOn(61, 100); // voice B starts in the SAME generation as the publish
|
||||||
|
|
||||||
|
std::vector<AudioSample> out;
|
||||||
|
for (int i = 0; i < 20; ++i) engine.render(out, 512); // let voice A's glide settle
|
||||||
|
|
||||||
|
// Both voices settle at 0.2 — a held voice that missed the publish would still sit at 1.0,
|
||||||
|
// for a combined 1.2 rather than 0.4.
|
||||||
|
CHECK(std::fabs(static_cast<double>(out.back()) - 0.4) < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testStageDurationChangeHoldsPhase();
|
testStageDurationChangeHoldsPhase();
|
||||||
testShortenedStageStillLandsContinuously();
|
testShortenedStageStillLandsContinuously();
|
||||||
@@ -1189,6 +1221,7 @@ int main() {
|
|||||||
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
testAPublishedPitchOffsetRefitsThePitchEnvelopeSpan();
|
||||||
testPitchRatioAndVelocityGainStayLatched();
|
testPitchRatioAndVelocityGainStayLatched();
|
||||||
testVelocityGainSurvivesAHostilePublishThatReallyLands();
|
testVelocityGainSurvivesAHostilePublishThatReallyLands();
|
||||||
|
testHeldVoiceReceivesALivePublishThatCoincidesWithANoteOn();
|
||||||
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
if (g_fail == 0) std::printf("live_delivery tests passed\n");
|
||||||
return g_fail == 0 ? 0 : 1;
|
return g_fail == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user