Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work

This commit is contained in:
2026-09-25 14:17:20 -04:00
47 changed files with 2150 additions and 757 deletions
+8 -3
View File
@@ -26,10 +26,11 @@ add_subdirectory(filter)
# marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no
# engine — the block is a plain value the voice observes, not a thing the engine owns.
# The live-parameter block and the drawn-contour block beside it: value layers plus their
# publication, deliberately linking no engine — a block is a plain value the voice observes, not
# a thing the engine owns.
reasampler_pure_library(live_params
SOURCES live_params.cpp
SOURCES live_params.cpp live_contours.cpp
LINK PUBLIC peaks velocity_curve filter curve_law)
reasampler_test(live_params LINK live_params)
@@ -52,6 +53,10 @@ reasampler_test(sampler_filter LINK sampler_core)
# does to a voice that is already sounding, and what it must leave alone.
reasampler_test(live_delivery LINK sampler_core)
# The drawn-contour half of live delivery. Its own executable because it replaces the global
# allocator to prove the audio-side swap allocates nothing, which must not leak into other tests.
reasampler_test(live_contours LINK sampler_core)
# The staged-envelope system across the same engine: per-segment curves, the sustain-less AHD
# both mode shapes share, and the Trigger tail's terminal behaviour.
reasampler_test(staged_envelopes LINK sampler_core)
@@ -0,0 +1,16 @@
// live_contours.cpp — the VelocityCurve -> fixed-capacity copy the contour block carries.
#include "core/instrument/engine/live_contours.h"
namespace reasampler::instrument::engine {
ContourPoints contourPoints(const VelocityCurve& curve) {
ContourPoints out{};
const std::vector<VelocityPoint>& pts = curve.points();
const std::size_t n = pts.size() < kMaxCurvePoints ? pts.size() : kMaxCurvePoints;
for (std::size_t i = 0; i < n; ++i) out.points[i] = pts[i];
out.count = static_cast<std::uint32_t>(n);
return out;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,50 @@
#pragma once
// live_contours.h — the three drawn EG contours as one fixed-capacity plain value, and the block
// that publishes them to the audio thread so a sounding voice follows a contour edit. Separate
// from LiveValues on purpose: at kMaxCurvePoints x 3 this is ~9 KB, and riding the live block
// would put it on every knob publish, automation merge and field compare.
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/seqlock.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler::instrument::engine {
// One contour's points, copied out of its VelocityCurve so the audio thread can hold it without
// a vector. `count` never exceeds kMaxCurvePoints — the curve's own ceiling — and the points
// past it are ignored.
struct ContourPoints {
std::uint32_t count = 0;
VelocityPoint points[kMaxCurvePoints];
};
// All three are carried whatever each envelope's mode: Staged|Spline is a note-on fact that
// travels by reload, so the voice decides which cursors to rebind and the block stays one shape.
struct LiveContours {
ContourPoints amp;
ContourPoints pitch;
ContourPoints filter;
};
static_assert(std::is_trivially_copyable_v<LiveContours>,
"the contour block is copied under a seqlock — it must stay a plain value");
ContourPoints contourPoints(const VelocityCurve& curve);
// Templated over the frames and seconds parameter representations for the same reason
// splineActive is (play_params.h): both spell the three fields identically. Off the audio thread.
template <class Play>
LiveContours foldContours(const Play& p) {
LiveContours c{};
c.amp = contourPoints(p.ampSpline.contour);
c.pitch = contourPoints(p.pitchSpline.contour);
c.filter = contourPoints(p.filterSpline.contour);
return c;
}
// A class rather than an alias so play_params.h can forward-declare it.
class LiveContourBlock : public Seqlock<LiveContours> {};
} // namespace reasampler::instrument::engine
+6 -61
View File
@@ -1,15 +1,15 @@
#pragma once
// live_params.h — the live playback-parameter block: the plain value bundle the audio thread
// observes once per BLOCK, the single-writer seqlock that publishes it without a lock or a
// torn read, the ONE fold from PlayParams that keeps the two representations in step, and the
// observes once per BLOCK, its publication (the seqlock.h protocol) without a lock or a torn
// read, the ONE fold from PlayParams that keeps the two representations in step, and the
// per-frame ramp that keeps a block-rate step inaudible. Ownership belongs above every
// instrument snapshot (see SampleData::live).
#include <atomic>
#include <cstdint>
#include <type_traits>
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/seqlock.h"
namespace reasampler::instrument::engine {
@@ -96,64 +96,9 @@ inline bool operator!=(const LiveValues& a, const LiveValues& b) { return !(a ==
// PlayParams — SampleData::keyTrack at the reload, InstrumentParams::keyTrack at a live commit.
LiveValues foldLive(const PlayParams& params, double keyTrack);
// Single-writer / single-reader seqlock. The writer publishes a whole block between an odd
// and an even generation; the reader copies the block and re-checks the generation, retrying
// a bounded number of times, so it can never act on a half-applied edit. Wait-free for the
// reader: after the retry budget it reports "nothing new" and the caller keeps its last good
// snapshot rather than spinning on the audio thread.
//
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
//
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
// value the audio thread can copy in one shot.
//
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign.
class LiveParams {
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const LiveValues& values) {
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
std::atomic_thread_fence(std::memory_order_release);
values_ = values;
std::atomic_thread_fence(std::memory_order_release);
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
std::uint32_t read(LiveValues& out, int maxAttempts = 4) const {
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
const std::uint32_t before = seq_.load(std::memory_order_acquire);
if (before == 0) return 0; // never published
if ((before & 1u) != 0u) continue; // writer mid-update
std::atomic_thread_fence(std::memory_order_acquire);
out = values_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == before) return before;
}
return 0;
}
private:
std::atomic<std::uint32_t> seq_{0};
LiveValues values_{};
};
// The live-parameter block's publication — seqlock.h owns the protocol and its single-writer
// contract. A class rather than an alias so play_params.h can forward-declare it.
class LiveParams : public Seqlock<LiveValues> {};
// 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,
+7 -3
View File
@@ -15,7 +15,8 @@
namespace reasampler {
namespace instrument::engine { class LiveParams; } // live_params.h; SampleData holds a pointer
// SampleData holds a pointer to each (live_params.h, live_contours.h).
namespace instrument::engine { class LiveParams; class LiveContourBlock; }
using audio::AudioSample;
using instrument::engine::VelocityCurve;
@@ -181,8 +182,8 @@ struct PlayParams {
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
// structs because those are copied whole into the live block, which must stay trivially
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
// curves it travels by reload.
// copyable (live_params.h). A contour reaches a sounding voice through its own block
// instead (live_contours.h); only its Staged|Spline mode travels by reload.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
@@ -299,6 +300,9 @@ struct SampleData {
// same knob as a live one. That is the desired behaviour — it is the note the user is
// hearing. Do not "fix" it by moving ownership into the snapshot.
const instrument::engine::LiveParams* live = nullptr;
// The drawn-contour peer of `live`, under the same ownership and null contract: null plays
// the contours in `play` for the note's whole life.
const instrument::engine::LiveContourBlock* liveContours = nullptr;
// A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays.
+76
View File
@@ -0,0 +1,76 @@
#pragma once
// seqlock.h — the lock-free publication every live block rides from its off-thread writer to
// the audio thread: a single-writer seqlock over one plain value. Generic over that value so the
// live-parameter block and the drawn-contour block share one implementation of the protocol.
#include <atomic>
#include <cstdint>
#include <type_traits>
namespace reasampler::instrument::engine {
// Single-writer seqlock. The writer publishes a whole block between an odd and an even
// generation; a reader copies the block and re-checks the generation, retrying a bounded number
// of times, so it can never act on a half-applied edit. Wait-free for the reader: after the
// retry budget it reports "nothing new" and the caller keeps its last good snapshot rather than
// spinning on the audio thread. Reading does not mutate, so any number of readers may share one
// block (the live and the draining engine both do).
//
// SINGLE-WRITER IS THE CALLER'S JOB and is load-bearing: two concurrent writers can leave the
// generation EVEN mid-write (A stores gen+1, B reads odd and stores gen+2) while both copy the
// block, and a reader then accepts a torn block as coherent. Every publisher must serialize.
//
// The plain (non-atomic) block copied across the fences is the standard pragmatic seqlock:
// the fences give correct ordering, but the concurrent read of a non-atomic object is a data
// race under the C++ object model, so TSan/UBSan will report it. That report is expected, not
// a defect — there is no clean lock-free standard-C++ alternative that keeps the block a plain
// value the audio thread can copy in one shot.
//
// The writer interface deliberately assumes NO particular thread beyond single-writer, so a
// host's own parameter-change queue (delivered on the audio thread with sample offsets) can
// drive it later without a redesign.
template <class T>
class Seqlock {
static_assert(std::is_trivially_copyable_v<T>,
"the block is copied under a seqlock — it must stay a plain value");
public:
// A generation of 0 means "never published"; the first publish lands on 2.
void publish(const T& values) {
const std::uint32_t gen = seq_.load(std::memory_order_relaxed);
seq_.store(gen + 1, std::memory_order_relaxed); // odd: a write is in progress
std::atomic_thread_fence(std::memory_order_release);
values_ = values;
std::atomic_thread_fence(std::memory_order_release);
// Skip 0 on wrap (~2^31 publishes): landing there would read as "never published" and
// stall every reader until the NEXT publish — a silent mode, unlike a loud one.
const std::uint32_t next = (gen + 2 == 0u) ? 2u : gen + 2;
seq_.store(next, std::memory_order_release); // even: complete and coherent
}
// The last generation published, without copying the block — one relaxed load, so a reader
// that only needs "has anything moved" pays nothing for asking on a block where nothing has.
std::uint32_t generation() const { return seq_.load(std::memory_order_relaxed); }
// Copies the block into `out` and returns the generation actually observed, or 0 when
// nothing has been published yet or the retry budget ran out (in which case `out` may hold
// a torn copy and MUST be discarded — compare the return against 0 before using it).
std::uint32_t read(T& out, int maxAttempts = 4) const {
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
const std::uint32_t before = seq_.load(std::memory_order_acquire);
if (before == 0) return 0; // never published
if ((before & 1u) != 0u) continue; // writer mid-update
std::atomic_thread_fence(std::memory_order_acquire);
out = values_;
std::atomic_thread_fence(std::memory_order_acquire);
if (seq_.load(std::memory_order_relaxed) == before) return before;
}
return 0;
}
private:
std::atomic<std::uint32_t> seq_{0};
T values_{};
};
} // namespace reasampler::instrument::engine
+11 -7
View File
@@ -224,17 +224,21 @@ private:
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling.
//
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
// Holds a RAW POINTER into the bound point array: the caller guarantees the array outlives the
// cursor, or rebinds it first. The voice binds against its SampleData (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 {
public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
// the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) {
const std::vector<VelocityPoint>& pts = c.points();
if (pts.size() < 2) { clear(); return; }
pts_ = pts.data();
n_ = pts.size();
void bind(const VelocityCurve& c) { bind(c.points().data(), c.points().size()); }
// The same over a raw X-ordered array (live_contours.h's fixed-capacity copy). Rebinding a
// cursor mid-read is legal: it restarts the segment cache, and the next eval re-locates.
void bind(const VelocityPoint* pts, std::size_t n) {
if (pts == nullptr || n < 2) { clear(); return; }
pts_ = pts;
n_ = n;
select(0);
}
void clear() { pts_ = nullptr; n_ = 0; }
+26
View File
@@ -111,6 +111,9 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
} else {
filterSplineCur_.clear();
}
ampSplineSmooth_.clear();
pitchSplineSmooth_.clear();
filterSplineSmooth_.clear();
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
@@ -336,6 +339,29 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
rMorph_.moving() || rDrive_.moving();
}
namespace {
// One EG's half of Voice::applyContours. `before` is read through the OLD binding — 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) {
// 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
+38 -9
View File
@@ -15,6 +15,7 @@
#include "core/instrument/engine/envelopes.h"
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
@@ -179,6 +180,14 @@ public:
// pitch ratio, and the decoded PCM stay latched at note-on.
void applyLive(const instrument::engine::LiveValues& live, bool snap);
// Re-points every drawn EG this note is ALREADY reading at `contours` — a cursor start()
// left cleared stays cleared, because Staged|Spline is a note-on fact. Block-boundary only,
// like applyLive. Without `snap` the level step at the current read position is absorbed
// by that EG's StepSmoother, the same way a live reshape of a positional AHD is; with it
// (a voice that has rendered nothing) the new contour is taken outright. `contours` must
// outlive the binding or be rebound first — VoiceEngine's double buffer owns that.
void applyContours(const instrument::engine::LiveContours& contours, bool snap);
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
@@ -271,6 +280,14 @@ private:
// its opening value.
double splinePhase() const { return readPos_ * splineScale_; }
// One drawn EG's value this frame: the contour at the read position plus whatever step a
// live contour swap left to absorb (applyContours). The smoother is inactive at rest, so a
// contour nobody is editing costs one predicted branch over the bare cursor read.
double readSpline(SplineCursor& cursor, StepSmoother& smooth) {
const double v = cursor.eval(splinePhase());
return smooth.active() ? v + smooth.advance() : v;
}
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
@@ -287,11 +304,17 @@ private:
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
amp = ampSplineCur_.eval(splinePhase());
amp = readSpline(ampSplineCur_, ampSplineSmooth_);
// 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() &&
ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true;
}
if (amp < 0.0) amp = 0.0;
else if (amp > 1.0) amp = 1.0;
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
@@ -324,7 +347,7 @@ private:
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted.
const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase())
? readSpline(filterSplineCur_, filterSplineSmooth_)
: ((playMode_ == PlayMode::Gate)
? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset()));
@@ -517,9 +540,10 @@ private:
seedTerminalDeclick();
}
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchSplineCur_.active()
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
: pitchEnv_.tick();
const double pitchEnvSemis =
pitchSplineCur_.active()
? pitchSplineDepth_ * readSpline(pitchSplineCur_, pitchSplineSmooth_)
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
// pow entirely — no per-frame transcendental on the common path.
@@ -752,13 +776,18 @@ private:
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
// SampleData outlives the voice — same contract as sample_) and, when the engine observes a
// contour block, re-pointed at its engine's live copy (applyContours). A Staged EG leaves its
// cursor inactive, so a purely staged instrument's per-sample path gains three predicted
// branches and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every
// contour shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
// Each smoother holds the step a live contour swap left, in the contour's own y domain.
SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_;
StepSmoother ampSplineSmooth_;
StepSmoother pitchSplineSmooth_;
StepSmoother filterSplineSmooth_;
double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0;
+27 -1
View File
@@ -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) {
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 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.
@@ -65,6 +87,8 @@ void VoiceEngine::startVoice(Voice& voice, int note, int velocity) {
voice.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_, rate, keyTrack,
lengthFraction);
if (haveLive_) voice.applyLive(live_, /*snap=*/true);
// The snapshot's contours are stale after a live edit, exactly as its play params are.
if (haveContours_) voice.applyContours(contours_[contourFront_], /*snap=*/true);
voice.setStartOrder(nextStartOrder_++);
}
@@ -258,6 +282,7 @@ void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
// audio thread never touches the heap.
if (out == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
@@ -274,6 +299,7 @@ void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t fram
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
if (left == nullptr || right == nullptr || frameCount == 0) return;
applyLiveToActive(); // block boundary, once — never inside the frame loop
applyContoursToActive();
for (Voice& voice : voices_) {
if (!voice.active()) continue;
for (std::size_t f = 0; f < frameCount; ++f) {
+22
View File
@@ -10,6 +10,7 @@
#include <vector>
#include "core/audio/peaks.h"
#include "core/instrument/engine/live_contours.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/voice.h"
@@ -57,6 +58,11 @@ public:
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
bool takeoverDeclick = false);
// Voices may hold raw pointers into this engine's own contour buffers, so a copy would
// leave them reading the original's.
VoiceEngine(const VoiceEngine&) = delete;
VoiceEngine& operator=(const VoiceEngine&) = delete;
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
@@ -124,6 +130,22 @@ private:
std::uint32_t liveGeneration_ = 0; // last generation observed; 0 = none yet
bool haveLive_ = false;
// --- Drawn-contour observation (live_contours.h) ---
// Reads a new contour generation, when one landed, into the buffer no voice is bound to and
// rebinds EVERY active voice to it before returning — which is what keeps the other buffer
// free for the next read. That invariant is why this, and never a bare read, is what both
// the block boundary and startVoice call.
void applyContoursToActive();
// Double-buffered because a voice's SplineCursor points INTO the buffer: reading over it
// would change a contour under a sounding voice without the rebind that absorbs the step,
// and a torn read (which the seqlock only reports after the fact) must land somewhere no
// voice reads. ~9 KB each, in the engine, so nothing on the audio thread allocates.
std::array<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
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;