Merge dev into ux-w1-t1-sample-rate: take the spline live-drag and plugin-drop work
This commit is contained in:
@@ -230,6 +230,14 @@ automatable parameters."* It rejects the precedent, not one instance of it.
|
||||
- **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the
|
||||
voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by
|
||||
the envelope's own bounded offset smoother.
|
||||
- **A drawn contour is live too, through a second block.** `LiveContours` (all three contours
|
||||
as fixed-capacity arrays, ~9 KB) is published beside `LiveValues` rather than inside it, so a
|
||||
knob publish, the automation merge and the block compare never carry it. Each `VoiceEngine`
|
||||
double-buffers it, and `applyContoursToActive` (`voice_engine.h`) owns the rebind invariant
|
||||
that makes the double buffer safe. A cursor `start()` left cleared (a Staged EG) stays
|
||||
cleared: Staged|Spline is a note-on fact. A contour is positional, like the Trigger AHD,
|
||||
so a swap is a level step at the read position, absorbed by a per-EG `StepSmoother` — one more
|
||||
predicted branch per sample on a drawn EG, none on a Staged one.
|
||||
|
||||
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
|
||||
|
||||
@@ -282,8 +290,9 @@ consequences, each with one home:
|
||||
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
|
||||
rescale, so a different-length capture replays the same shape proportionally.
|
||||
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
|
||||
structs.** Those are copied whole into the live block, which must stay trivially copyable
|
||||
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
|
||||
structs.** Those are copied whole into the live block, which must stay small and trivially
|
||||
copyable (`live_params.h`). A contour still reaches the note already sounding — through its
|
||||
OWN block (`live_contours.h`), see "Live parameter delivery". Only the Staged|Spline mode
|
||||
travels by reload.
|
||||
|
||||
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
|
||||
@@ -321,7 +330,7 @@ anything for a trigger shape.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions. Also the ONE home of the drawn-EG rule family — `splineActive`, `effectivePlayMode`, `enforceGateUnavailableWhileDrawn` and `effectiveLengthFraction` — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), the block's FIELD-wise `operator==` (never a memcmp — the header owns why the padding makes a byte compare report differences that do not exist), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns. The same library carries `live_contours.h`/`.cpp` (the drawn-contour block beside it, and `foldContours`, templated over both parameter representations like `splineActive`) and the header-only `seqlock.h`, the ONE seqlock both blocks publish through.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`. **Documented ~600-line-ceiling exception** (root `CLAUDE.md` structural heuristic 1): `voice.h` sits over the ceiling because `advanceFrame`'s RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
@@ -423,7 +432,7 @@ anything for a trigger shape.
|
||||
envelope that was never running.
|
||||
- **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the
|
||||
snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by
|
||||
`snapLive` before its first frame.
|
||||
`snapLive` (and a drawn EG by `applyContours`' snap) before its first frame.
|
||||
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
|
||||
covers client-pixel widths (including multiples standing in for larger client areas);
|
||||
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
|
||||
|
||||
@@ -39,6 +39,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
|
||||
// this SampleData's own play params, which is what the bake is meant to print.
|
||||
sample.live = nullptr;
|
||||
sample.liveContours = nullptr;
|
||||
|
||||
const int channels = sample.channelCount();
|
||||
// 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.
|
||||
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
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
// 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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+26
-11
@@ -86,16 +86,30 @@ L7 sub-pass, 2026-07-27):
|
||||
- **The drag-out law is per-move and stateless.** The class is resolved from the
|
||||
current pointer on every move and again at the release point; nothing is latched
|
||||
between evaluations. That is what makes every transition reversible and what
|
||||
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
|
||||
REAPER entirely is the one irreversible transition, because the OS hand-off goes
|
||||
modal — so it must be PROVEN (a window-ownership test in the shell), never inferred
|
||||
from a hit-test token REAPER could not name; an unrecognised surface *inside* REAPER
|
||||
refuses. That proof is necessary but NOT sufficient: a plugin bridged into
|
||||
`reaper_host*.exe` fails the process test while still being REAPER's own UI, so the
|
||||
hand-off additionally requires REAPER's hit-test to have named nothing — a recognised
|
||||
token can only ever veto the hand-off, never produce one. A first-move class lock and
|
||||
a drag-lifetime "cannot hand off" latch both existed here and were removed — do not
|
||||
reintroduce either.
|
||||
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. The OS
|
||||
hand-off goes modal, so it must be PROVEN by a window test in the shell, never
|
||||
inferred from a hit-test token REAPER could not name; an unrecognised surface
|
||||
*inside* REAPER refuses. There are exactly two proofs, each necessary but NOT
|
||||
sufficient, each paired with a hit-test condition:
|
||||
- **Leaving REAPER** (process ownership). A plugin bridged into `reaper_host*.exe`
|
||||
fails the process test while still being inside REAPER, so this route also requires
|
||||
REAPER's hit-test to have named nothing — here a recognised token can only VETO.
|
||||
- **A plugin's own editor** (code ownership, `window_origin`). The proof alone would
|
||||
hand off over any extension window docked in REAPER, so this route also REQUIRES the
|
||||
FX-surface token; within an FX window the proof is what separates the plugin's editor
|
||||
(OS hand-off — the plugin takes the files as it would from Explorer) from REAPER's
|
||||
chrome (instrument drop, unchanged). Our own ReaSampler 9000 editor is exempted from
|
||||
this proof (`WindowCode::Instrument`) — it is FX chrome we own, so a drop on it is the
|
||||
instrument-drop outcome like any other REAPER chrome, never the OS hand-off.
|
||||
|
||||
Once handed off, the law keeps running: on Windows the OS drag loop re-asks it every
|
||||
turn (`decideOsDragTurn`) and WITHDRAWS the moment it stops answering `OsHandoff`,
|
||||
giving the gesture back to the panel — or, at a release, committing REAPER's own
|
||||
outcome at that point. So neither a pass over a plugin's editor nor a return from
|
||||
outside REAPER costs a REAPER-internal outcome. SWELL's file drag exposes no such hook,
|
||||
so off Windows the hand-off stays one-way (and there is no plugin-editor route there).
|
||||
A first-move class lock and a drag-lifetime "cannot hand off" latch both existed here
|
||||
and were removed — do not reintroduce either.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -103,7 +117,8 @@ L7 sub-pass, 2026-07-27):
|
||||
- `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel.
|
||||
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
|
||||
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
|
||||
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through `DropContext::pointerOffHost` — a positive window-ownership proof the shell owes the law (`drag_out_win::pointerOverHostWindow`) — AND only where REAPER's own hit-test named nothing (`Other`, no track), gated ahead of the surface switch, so neither evidence source can give a live drag away to OLE on its own. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
|
||||
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
|
||||
- `window_origin` — whose code implements a window (Host / System / Plugin / Instrument / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. `Instrument` is the one deliberate exemption: our own ReaSampler 9000 module (`WindowFacts::ownModule`, a basename match against this channel's `app_version::vstOutputName() + ".vst3"` — stable and beta each recognise only their own binary) is FX chrome we own, not a hand-off-worthy plugin editor, so it is never `Plugin` and never counts as `isPluginEditorChain` proof — a drag over our own editor stays the instrument-drop outcome. Every other plugin's module is unaffected. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`.
|
||||
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
|
||||
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
|
||||
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
|
||||
|
||||
@@ -10,6 +10,9 @@ reasampler_test(prune_button LINK prune_button)
|
||||
reasampler_pure_library(drag_out SOURCES drag_out.cpp)
|
||||
reasampler_test(drag_out LINK drag_out)
|
||||
|
||||
reasampler_pure_library(window_origin SOURCES window_origin.cpp)
|
||||
reasampler_test(window_origin LINK window_origin)
|
||||
|
||||
reasampler_pure_library(theme SOURCES theme.cpp)
|
||||
reasampler_test(theme LINK theme)
|
||||
|
||||
|
||||
@@ -24,17 +24,24 @@ DropClass decideDropClass(int px, int py, const PanelClientRect& client,
|
||||
if (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
|
||||
if (insideClient(px, py, client)) return DropClass::Internal;
|
||||
|
||||
// The ONE route to OsHandoff, ahead of the surface switch, and it needs BOTH halves. The
|
||||
// shell's ownership proof alone is not sufficient: REAPER runs a bridged plugin's UI in a
|
||||
// separate process, so a window failing that test can still be REAPER's own UI. A recognised
|
||||
// token therefore VETOES the hand-off instead of producing one — a veto can only move the
|
||||
// answer toward staying inside REAPER, so pointerOffHost remains the only member whose true
|
||||
// value can reach OLE. An off-host point REAPER DID name falls through to that surface's own
|
||||
// outcome below: over a bridged FX window that is the instrument drop, as it is for a native
|
||||
// one.
|
||||
// The two routes to OsHandoff, both ahead of the surface switch, each needing BOTH halves.
|
||||
//
|
||||
// Leaving REAPER. The process proof alone is not sufficient: REAPER runs a bridged plugin's UI
|
||||
// in a separate process, so a window failing that test can still be inside REAPER. A
|
||||
// recognised token therefore VETOES this route instead of producing it — a veto can only move
|
||||
// the answer toward staying inside REAPER. An off-host point REAPER DID name falls through to
|
||||
// the second route, then to that surface's own outcome below.
|
||||
if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack)
|
||||
return DropClass::OsHandoff;
|
||||
|
||||
// A plugin's own editor, with the reverse pairing: REAPER's naming is REQUIRED here, not a
|
||||
// veto. The code proof alone would hand off over any extension window docked in REAPER; the
|
||||
// FX-surface token confines it to FX windows, and the proof picks the plugin's editor out of
|
||||
// that window's REAPER chrome, which keeps its instrument outcome below. A bridged editor
|
||||
// lands here too — the token that vetoed the first route admits this one.
|
||||
if (ctx.pointerOverPluginEditor && ctx.surface == ReaperSurface::FxSurface)
|
||||
return DropClass::OsHandoff;
|
||||
|
||||
switch (ctx.surface) {
|
||||
case ReaperSurface::TrackPanel:
|
||||
case ReaperSurface::FxSurface:
|
||||
@@ -100,4 +107,10 @@ OsHandoff decideOsHandoff(const std::vector<std::string>& paths) {
|
||||
return out;
|
||||
}
|
||||
|
||||
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff) {
|
||||
if (escapePressed) return OsDragTurn::Cancel;
|
||||
if (buttonDown) return lawHandsOff ? OsDragTurn::Continue : OsDragTurn::Withdraw;
|
||||
return lawHandsOff ? OsDragTurn::Drop : OsDragTurn::WithdrawAtRelease;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
+38
-12
@@ -4,9 +4,10 @@
|
||||
// hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
|
||||
// instrument_drop_win / arrange_drop_win / drag_out_win).
|
||||
//
|
||||
// THE LAW: the class is resolved from what is under the cursor on EVERY move; every transition
|
||||
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is
|
||||
// reserved for leaving REAPER (DropContext::pointerOffHost owns what proves that) and every
|
||||
// THE LAW: the class is resolved from what is under the cursor on EVERY move — and on every turn
|
||||
// of the OS drag loop once handed off (decideOsDragTurn), so every transition is reversible until
|
||||
// release wherever the OS loop can be re-asked; the OS hand-off is reserved for targets that are
|
||||
// not REAPER's own UI (DropContext's two proof members own what establishes that) and every
|
||||
// REAPER-internal target executes natively on release. Do not reintroduce a first-move class
|
||||
// lock or a drag-lifetime "blocked" latch.
|
||||
//
|
||||
@@ -55,7 +56,7 @@ enum class DropClass {
|
||||
InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
|
||||
ArrangeInsert, // the timeline — place items at the pointer's track and time
|
||||
Refuse, // a REAPER surface with no defined outcome for this payload — cue it
|
||||
OsHandoff, // the pointer left REAPER — hand the file list to the OS
|
||||
OsHandoff, // not REAPER's own UI (off-host, or a plugin's editor) — hand the files to the OS
|
||||
};
|
||||
|
||||
// Everything the law reads. Nothing here is remembered between evaluations: two evaluations at
|
||||
@@ -65,15 +66,22 @@ struct DropContext {
|
||||
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
|
||||
ReaperSurface surface = ReaperSurface::Other; // only read outside the client rect
|
||||
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack*
|
||||
// THE ONE HOME for the hand-off's evidence rule. This is the only member whose true value
|
||||
// can reach the irreversible OS hand-off, and it is a POSITIVE proof the shell owes the law
|
||||
// — a window-ownership test (drag_out_win::pointerOverHostWindow), never "the hit-test named
|
||||
// nothing I recognise", which is equally true of the transport, the toolbar and the docker
|
||||
// chrome; reading an unnamed token as an exit is what handed live drags to OLE mid-gesture.
|
||||
// Defaults false so an unset context stays inside REAPER: the failure mode of forgetting to
|
||||
// resolve it must be a refusal, never a drag given away to OLE. NECESSARY but not sufficient
|
||||
// — decideDropClass's gate carries the second half and why it needs one.
|
||||
// THE ONE HOME for the hand-off's evidence rule. This member and pointerOverPluginEditor are
|
||||
// the only two whose true value can reach the modal OS hand-off, and each is a POSITIVE proof
|
||||
// the shell owes the law — a window test, never "the hit-test named nothing I recognise",
|
||||
// which is equally true of the transport, the toolbar and the docker chrome; reading an
|
||||
// unnamed token as an exit is what handed live drags to OLE mid-gesture. Both default false
|
||||
// so an unset context stays inside REAPER: forgetting to resolve one must be a refusal, never
|
||||
// a drag given away to OLE. Each is NECESSARY but not sufficient — decideDropClass pairs each
|
||||
// with a hit-test condition, and says why.
|
||||
//
|
||||
// Process ownership (drag_out_win::pointerOverHostWindow): the window under the pointer is not
|
||||
// the host process's.
|
||||
bool pointerOffHost = false;
|
||||
// Code ownership (drag_out_win::pointerOverPluginEditor, deciding via ui::window_origin): the
|
||||
// window under the pointer, or one of its ancestors, is implemented by plugin code — a plugin's
|
||||
// own editor, which takes files the way it takes them from Explorer.
|
||||
bool pointerOverPluginEditor = false;
|
||||
};
|
||||
|
||||
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
|
||||
@@ -131,4 +139,22 @@ struct OsHandoff {
|
||||
// means the internal drag stays live rather than dying half-torn-down.
|
||||
OsHandoff decideOsHandoff(const std::vector<std::string>& paths);
|
||||
|
||||
// --- The OS drag loop, once handed off ---------------------------------------------
|
||||
|
||||
// One turn of the OS drag loop. The hand-off does NOT end the law: the loop re-asks it at the
|
||||
// current pointer every turn, and the moment it stops answering OsHandoff the OS drag is
|
||||
// WITHDRAWN and the gesture given back to the panel — which is what keeps a pass over a plugin's
|
||||
// editor, or a return from outside REAPER, from costing the user every REAPER-internal outcome.
|
||||
enum class OsDragTurn {
|
||||
Continue, // still over an OS target, button held
|
||||
Drop, // released over an OS target: the OS delivers the files
|
||||
Cancel, // Escape: the whole drag ends, nothing happens (unchanged)
|
||||
Withdraw, // button held over a REAPER surface: resume the internal drag
|
||||
WithdrawAtRelease, // released over a REAPER surface: resume, then commit it at that point
|
||||
};
|
||||
|
||||
// Escape outranks everything, so a cancel can never be read as a release anywhere.
|
||||
// `lawHandsOff` is decideDropClass(...) == OsHandoff at the current pointer; ignored on Escape.
|
||||
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// window_origin — pure implementation. See window_origin.h.
|
||||
|
||||
#include "core/ui/window_origin.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Case-folded (ASCII only — Windows' own directory names are ASCII), separator-unified, with the
|
||||
// "\\?\" long-path prefix GetModuleFileName may report dropped, so the same directory compares
|
||||
// equal however each API chose to spell it.
|
||||
std::string normalizePath(const std::string& p) {
|
||||
std::string s = p.rfind("\\\\?\\", 0) == 0 ? p.substr(4) : p;
|
||||
for (char& c : s) {
|
||||
if (c == '/') c = '\\';
|
||||
else if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
}
|
||||
while (!s.empty() && s.back() == '\\') s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Directory containment, not string prefix: C:\Windows must not claim C:\WindowsApps.
|
||||
bool isBeneath(const std::string& path, const std::string& dir) {
|
||||
const std::string p = normalizePath(path);
|
||||
const std::string d = normalizePath(dir);
|
||||
return !d.empty() && p.size() > d.size() && p.compare(0, d.size(), d) == 0 &&
|
||||
p[d.size()] == '\\';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot) {
|
||||
// Another process's procedure address means nothing in ours; the process boundary is itself
|
||||
// the proof. Inside an FX window that is the bridged plugin's editor.
|
||||
if (facts.otherProcess) return WindowCode::Plugin;
|
||||
if (!facts.procResolved) return WindowCode::Unknown;
|
||||
if (facts.hostModule) return WindowCode::Host;
|
||||
if (facts.ownModule) return WindowCode::Instrument;
|
||||
if (normalizePath(systemRoot).empty()) return WindowCode::Unknown;
|
||||
return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin;
|
||||
}
|
||||
|
||||
bool isPluginEditorChain(const std::vector<WindowCode>& chain) {
|
||||
return std::find(chain.begin(), chain.end(), WindowCode::Plugin) != chain.end();
|
||||
}
|
||||
|
||||
bool isOwnModulePath(const std::string& path, const std::string& ownName) {
|
||||
const std::size_t pos = path.find_last_of("\\/");
|
||||
const std::string base = pos == std::string::npos ? path : path.substr(pos + 1);
|
||||
if (base.size() != ownName.size()) return false;
|
||||
for (std::size_t i = 0; i < base.size(); ++i) {
|
||||
char a = base[i], b = ownName[i];
|
||||
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
|
||||
if (b >= 'A' && b <= 'Z') b = static_cast<char>(b - 'A' + 'a');
|
||||
if (a != b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
// window_origin — whose code implements a window, and whether the window chain under the pointer
|
||||
// is a plugin's own editor rather than REAPER's chrome. The shell reads the facts (owning process,
|
||||
// the module holding the window CLASS's procedure); this module decides, so the rule is provable
|
||||
// without a DAW. Evidence feeding ui::DropContext::pointerOverPluginEditor.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
enum class WindowCode {
|
||||
Host, // the host executable itself — REAPER's own chrome and containers
|
||||
System, // an OS module (standard controls, dialog class): says nothing about who placed it
|
||||
Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin)
|
||||
Instrument, // our own ReaSampler 9000 module: FX chrome, not a hand-off-worthy plugin editor
|
||||
Unknown, // the procedure resolved to no loaded module — no evidence either way
|
||||
};
|
||||
|
||||
// What the shell observed about ONE window. The CLASS procedure, not the instance one, because an
|
||||
// extension subclassing a REAPER window swaps only the instance procedure — reading that would
|
||||
// make REAPER's own chrome look like foreign code.
|
||||
struct WindowFacts {
|
||||
bool otherProcess = false; // the window belongs to a process other than the host's
|
||||
bool procResolved = false; // the class procedure lies inside a loaded module
|
||||
bool hostModule = false; // ...and that module is the host executable
|
||||
// ...and that module is THIS channel's ReaSampler 9000 .vst3. Rationale: core/ui/CLAUDE.md's
|
||||
// window_origin entry (the ownModule/Instrument exemption).
|
||||
bool ownModule = false;
|
||||
std::string modulePath; // that module's file path, UTF-8
|
||||
};
|
||||
|
||||
// `systemRoot` is the OS directory (GetSystemWindowsDirectory); a module beneath it is System.
|
||||
// An empty root makes every non-host module Unknown rather than Plugin: without it a standard
|
||||
// button in REAPER's chrome would be indistinguishable from plugin code.
|
||||
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot);
|
||||
|
||||
// `chain` runs from the window under the pointer up to and including its top-level root. True iff
|
||||
// any window in it is Plugin code: a plugin's editor sits beneath REAPER's container, and a
|
||||
// standard control INSIDE that editor is still under the plugin's own window. Unknown is never
|
||||
// proof — the hand-off this gates is modal, so it needs positive evidence.
|
||||
bool isPluginEditorChain(const std::vector<WindowCode>& chain);
|
||||
|
||||
// True iff `path`'s file name (the part after the last separator, forward or back) matches
|
||||
// `ownName` case-insensitively — see WindowFacts::ownModule for why this decides Instrument vs
|
||||
// Plugin. `path` may be a bundle path (only the final component is compared).
|
||||
bool isOwnModulePath(const std::string& path, const std::string& ownName);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
// instrument_drop — pure payload-construction core of drop-and-load: dropping a
|
||||
// bank capture onto a track's panel or FX surface instantiates ReaSampler 9000 on
|
||||
// that track already playing that capture. Also the classifier that names those
|
||||
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// bank capture onto a track's panel or an FX window's REAPER chrome instantiates
|
||||
// ReaSampler 9000 on that track already playing that capture (a plugin's own editor
|
||||
// inside an FX window takes the files instead — ui::decideDropClass). Also the
|
||||
// classifier that names those surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// (+ the pure sample_map it reuses and the SDK-free UID macros in
|
||||
// reasampler_uid.h); unit-tested outside the DAW.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user