Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes.
|
||||
|
||||
#include "core/instrument/engine/master_gain.h"
|
||||
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
|
||||
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
||||
|
||||
double masterGainDbFromNorm(double norm) {
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) return -std::numeric_limits<double>::infinity();
|
||||
return kMasterGainMinDb + norm * (kMasterGainMaxDb - kMasterGainMinDb);
|
||||
}
|
||||
|
||||
double masterGainNormFromDb(double db) {
|
||||
if (!(db > kMasterGainMinDb)) return 0.0; // -inf, NaN, and the floor all read 0
|
||||
return clamp01((db - kMasterGainMinDb) / (kMasterGainMaxDb - kMasterGainMinDb));
|
||||
}
|
||||
|
||||
double masterGainLinearFromNorm(double norm) {
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) return 0.0; // TRUE silence at the bottom — not an epsilon
|
||||
return std::pow(10.0, masterGainDbFromNorm(norm) / 20.0);
|
||||
}
|
||||
|
||||
double masterGainNormFromLinear(double linear) {
|
||||
if (!std::isfinite(linear) || linear <= 0.0) return 0.0;
|
||||
return masterGainNormFromDb(20.0 * std::log10(linear));
|
||||
}
|
||||
|
||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
|
||||
if (!buf || len == 0) return;
|
||||
norm = clamp01(norm);
|
||||
if (norm <= 0.0) {
|
||||
std::snprintf(buf, len, "-inf");
|
||||
return;
|
||||
}
|
||||
const double db = masterGainDbFromNorm(norm);
|
||||
std::snprintf(buf, len, "%+.1fdB", db);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,58 @@
|
||||
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
|
||||
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
|
||||
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
|
||||
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
|
||||
// and the audio-thread multiply can never drift.
|
||||
//
|
||||
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
|
||||
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
|
||||
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
|
||||
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
|
||||
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
|
||||
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
|
||||
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
|
||||
//
|
||||
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
|
||||
// output — these functions run on the UI/state threads only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
|
||||
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
|
||||
inline constexpr double kMasterGainMinDb = -60.0;
|
||||
inline constexpr double kMasterGainMaxDb = 24.0;
|
||||
|
||||
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
|
||||
double masterGainMaxLinear();
|
||||
|
||||
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
|
||||
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
|
||||
double masterGainDbFromNorm(double norm);
|
||||
|
||||
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
|
||||
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
|
||||
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
|
||||
// to the same true-zero bottom. +24 -> 1. Pure.
|
||||
double masterGainNormFromDb(double db);
|
||||
|
||||
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
|
||||
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
|
||||
double masterGainLinearFromNorm(double norm);
|
||||
|
||||
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
|
||||
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
|
||||
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
|
||||
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
|
||||
// Out-of-range/non-finite input clamps. Pure.
|
||||
double masterGainNormFromLinear(double linear);
|
||||
|
||||
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
|
||||
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
|
||||
// including the terminator. Pure.
|
||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,434 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
|
||||
// the naive dual-tap OLA with correlation-aligned splices.
|
||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
||||
//
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
|
||||
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
|
||||
// one window (+window toward older content for up-shifts, -window toward the writer for
|
||||
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
|
||||
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
|
||||
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
|
||||
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
|
||||
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
|
||||
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
|
||||
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
|
||||
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
|
||||
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
|
||||
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
|
||||
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
|
||||
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
} // namespace
|
||||
|
||||
void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
window_ = windowFrames;
|
||||
if (window_ <= 1) {
|
||||
// Pass-through: no ring, process() returns input unchanged.
|
||||
ring_.clear();
|
||||
ringLen_ = 0;
|
||||
writePos_ = 0;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeFrames_ = fadeLen_ = maxLag_ = corrFrames_ = dLow_ = dHigh_ = 0;
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
return;
|
||||
}
|
||||
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
|
||||
ringLen_ = 2 * window_;
|
||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||
// Geometry (all quarters of the window):
|
||||
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
|
||||
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
|
||||
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
|
||||
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
|
||||
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
|
||||
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
|
||||
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
|
||||
// between the tap and the writer — the cap expresses that safety rather than leaving
|
||||
// it coincidental. 512 bounds the splice burst.
|
||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||
maxLag_ = window_ / 4;
|
||||
dLow_ = window_ / 4;
|
||||
dHigh_ = ringLen_ - window_ / 4;
|
||||
corrFrames_ = std::max<std::int64_t>(1, std::min<std::int64_t>(dLow_ - 1, 512));
|
||||
fadeLen_ = 0;
|
||||
reset();
|
||||
}
|
||||
|
||||
void PitchShifter::reset() {
|
||||
if (window_ > 1) {
|
||||
// Zero the ring and seed the active tap one window behind the writer — the exact
|
||||
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
|
||||
// forever and either shift direction has maximal drift room. No history is declared
|
||||
// (filled_ = 0): follow with prime() or warm() before streaming.
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = 0;
|
||||
posA_ = static_cast<double>(ringLen_ - window_);
|
||||
posB_ = posA_;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeLen_ = 0;
|
||||
} else {
|
||||
writePos_ = 0;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeLen_ = 0;
|
||||
}
|
||||
filled_ = 0;
|
||||
ratio_ = 1.0;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
}
|
||||
|
||||
void PitchShifter::freezeTail() {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
tailFrozen_ = true;
|
||||
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
|
||||
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
|
||||
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
|
||||
// continuous at the freeze frame (no gain step); see the re-anchor block below.
|
||||
if (fading_) {
|
||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
|
||||
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
|
||||
const double tOld =
|
||||
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
double dB = static_cast<double>(writePos_) - posB_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (dB < 0.0) dB += len;
|
||||
while (dB >= len) dB -= len;
|
||||
// Clamp in double before the int64 cast (matches splice() pattern; guards against UB
|
||||
// when dB/ratio_ is very large, e.g. near-unity ratio at a high sample rate).
|
||||
double left = (dB - 2.0) / ratio_;
|
||||
if (left > static_cast<double>(fadeFrames_)) left = static_cast<double>(fadeFrames_);
|
||||
const std::int64_t leftFrames = left > 1.0 ? static_cast<std::int64_t>(left) : 1;
|
||||
const std::int64_t newFadeLen = std::min(fadeLen_, fadePos_ + leftFrames);
|
||||
// Re-anchor: tOld < 1 because we are mid-fade, so newFadePos < newFadeLen (still fading).
|
||||
fadePos_ = static_cast<std::int64_t>(tOld * static_cast<double>(newFadeLen));
|
||||
fadeLen_ = newFadeLen;
|
||||
}
|
||||
}
|
||||
|
||||
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
||||
if (window_ <= 1) return; // pass-through needs no priming
|
||||
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
|
||||
// count must stay inside the safe band so the seed does not itself trigger a splice.
|
||||
if (count < 0) count = 0;
|
||||
if (count > window_) count = window_;
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
for (std::int64_t i = 0; i < count; ++i) ring_[static_cast<std::size_t>(i)] = src[i];
|
||||
// Writer continues after the primed span; the tap parks ON src[0] (delay == count), so
|
||||
// the very first process() output is src[0] — zero structural latency at every ratio.
|
||||
writePos_ = count % ringLen_;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeLen_ = 0;
|
||||
filled_ = count;
|
||||
tailFrozen_ = false; // a fresh note-on always starts with a live writer
|
||||
lastSplice_ = SpliceEvent{};
|
||||
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
|
||||
}
|
||||
|
||||
void PitchShifter::warm() {
|
||||
if (window_ <= 1) return; // pass-through needs no warm-up
|
||||
// A prime() with one window of silence: same geometry (tap parked mid-band one window
|
||||
// behind the writer), the zeros declared as valid history. At unity this is a bit-exact
|
||||
// window() delay; an up-shift plays ~a window of silence before speaking (the pre-GA2
|
||||
// onset) — stream callers with access to the upcoming source should prime() instead.
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = window_ % ringLen_;
|
||||
posA_ = posB_ = 0.0;
|
||||
fading_ = false;
|
||||
fadePos_ = 0;
|
||||
fadeLen_ = 0;
|
||||
filled_ = window_;
|
||||
tailFrozen_ = false;
|
||||
lastSplice_ = SpliceEvent{};
|
||||
}
|
||||
|
||||
void PitchShifter::setShiftRatio(double ratio) {
|
||||
if (ratio > 0.0) ratio_ = ratio; // ignore non-positive (never run the tap backward/stall)
|
||||
}
|
||||
|
||||
double PitchShifter::readTap(double pos) const {
|
||||
// Fractional linear interpolation with ring wrap.
|
||||
double p = pos;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(p);
|
||||
const double frac = p - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (i1 >= ringLen_) i1 = 0;
|
||||
const double s0 = static_cast<double>(ring_[static_cast<std::size_t>(i0)]);
|
||||
const double s1 = static_cast<double>(ring_[static_cast<std::size_t>(i1)]);
|
||||
return s0 + (s1 - s0) * frac;
|
||||
}
|
||||
|
||||
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
|
||||
// older content, -window_ = jump toward the writer), refined by a correlation search so
|
||||
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
|
||||
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
|
||||
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
|
||||
// multiply-adds, once per splice.
|
||||
const std::int64_t d = static_cast<std::int64_t>(delay);
|
||||
|
||||
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
|
||||
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
|
||||
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
|
||||
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
|
||||
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
|
||||
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
|
||||
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
|
||||
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
|
||||
// what real history exists (still many source periods with a full-window prime). The floor of
|
||||
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
|
||||
std::int64_t jump = nominalJump;
|
||||
if (jump > 0) {
|
||||
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
|
||||
if (jump > maxJump) jump = maxJump;
|
||||
if (jump < 1) jump = 1;
|
||||
}
|
||||
// The correlation reference reads FORWARD from the tap; keep it strictly behind the
|
||||
// writer even when the trigger undershot dLow_ by a large per-frame drift (extreme
|
||||
// up-ratios): d - corr must stay >= 0.
|
||||
const std::int64_t corr = std::max<std::int64_t>(1, std::min<std::int64_t>(corrFrames_, d - 1));
|
||||
|
||||
const std::int64_t iA =
|
||||
((static_cast<std::int64_t>(posA_) % ringLen_) + ringLen_) % ringLen_;
|
||||
|
||||
auto scoreAt = [&](std::int64_t lag) -> double {
|
||||
std::int64_t ia = iA;
|
||||
std::int64_t ic = ((iA - jump + lag) % ringLen_ + ringLen_) % ringLen_;
|
||||
double s = 0.0, ec = 0.0;
|
||||
for (std::int64_t k = 0; k < corr; ++k) {
|
||||
const double a = static_cast<double>(ring_[static_cast<std::size_t>(ia)]);
|
||||
const double c = static_cast<double>(ring_[static_cast<std::size_t>(ic)]);
|
||||
s += a * c;
|
||||
ec += c * c;
|
||||
if (++ia >= ringLen_) ia = 0;
|
||||
if (++ic >= ringLen_) ic = 0;
|
||||
}
|
||||
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
|
||||
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
|
||||
// loudest candidate over the best-ALIGNED one — a small level step per splice that
|
||||
// the amplitude-complementary fade cannot hide. The reference segment's energy is
|
||||
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
|
||||
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
|
||||
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
|
||||
};
|
||||
|
||||
std::int64_t bestLag = 0;
|
||||
double bestScore = -std::numeric_limits<double>::infinity();
|
||||
for (std::int64_t lag = -maxLag_; lag <= maxLag_; lag += 4) {
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
const std::int64_t coarse = bestLag;
|
||||
for (std::int64_t lag = coarse - 3; lag <= coarse + 3; ++lag) {
|
||||
if (lag == coarse || lag < -maxLag_ || lag > maxLag_) continue;
|
||||
const double s = scoreAt(lag);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestLag = lag;
|
||||
}
|
||||
}
|
||||
|
||||
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
|
||||
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
|
||||
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
|
||||
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
|
||||
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
|
||||
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
|
||||
// silence) keeps the integer lag, which is already benign there.
|
||||
double frac = 0.0;
|
||||
{
|
||||
const double sM = scoreAt(bestLag - 1);
|
||||
const double sP = scoreAt(bestLag + 1);
|
||||
const double den = sM - 2.0 * bestScore + sP;
|
||||
if (den < 0.0) {
|
||||
frac = 0.5 * (sM - sP) / den;
|
||||
if (frac > 0.5) frac = 0.5;
|
||||
if (frac < -0.5) frac = -0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the current position to the outgoing tap and relocate the active one.
|
||||
posB_ = posA_;
|
||||
double p = posA_ - static_cast<double>(jump) + static_cast<double>(bestLag) + frac;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
|
||||
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
|
||||
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
|
||||
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
|
||||
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
|
||||
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
|
||||
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
|
||||
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
|
||||
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
|
||||
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
|
||||
// mid-fade is covered by the same margin for any realistic per-frame bias.
|
||||
//
|
||||
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
|
||||
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
|
||||
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
|
||||
// included: splices fire in the frozen tail because the delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||
if (drainRate > 0.0) {
|
||||
const double headroom = static_cast<double>(dLow_) - drainRate - 2.0;
|
||||
// Clamp in double before the int64 cast to avoid UB at pathological near-unity ratios
|
||||
// at very high sample rates (where headroom/drainRate could overflow int64).
|
||||
const double safeDbl = headroom > 0.0
|
||||
? std::min(headroom / drainRate, static_cast<double>(fadeFrames_))
|
||||
: 1.0;
|
||||
fadeLen_ = std::max<std::int64_t>(1, static_cast<std::int64_t>(safeDbl));
|
||||
}
|
||||
fading_ = true;
|
||||
fadePos_ = 0;
|
||||
// Record the decision for a linked follower channel (T1-01): the follower applies this
|
||||
// verbatim so both channels share one lag and one splice schedule.
|
||||
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
|
||||
}
|
||||
|
||||
void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
|
||||
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
|
||||
// which match ours by the lockstep contract (identical configure/prime/ratio history);
|
||||
// the fade length likewise derives only from shared geometry + ratio.
|
||||
posB_ = posA_;
|
||||
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
fadeLen_ = std::max<std::int64_t>(1, ev.fadeLen);
|
||||
fading_ = true;
|
||||
fadePos_ = 0;
|
||||
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
|
||||
|
||||
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
|
||||
return processImpl(in, &master);
|
||||
}
|
||||
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
|
||||
// 5 plain fields, negligible on the RT path).
|
||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
|
||||
|
||||
// 1. Write the incoming sample at the write head (source rate). One more slot of the
|
||||
// ring now holds valid history (capped at the ring length once it has wrapped).
|
||||
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
|
||||
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
|
||||
// the read/splice/fade machinery below runs unchanged over the frozen content.
|
||||
if (!tailFrozen_) {
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
}
|
||||
|
||||
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
|
||||
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
double out = readTap(posA_);
|
||||
if (fading_) {
|
||||
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
|
||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||
if (++fadePos_ >= fadeLen_) fading_ = false;
|
||||
} else if (linked != nullptr) {
|
||||
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
|
||||
// master channel did this frame. Lockstep state means our own trigger would have
|
||||
// fired on the same frame; applying the master's decision keeps the two rings
|
||||
// sample-aligned (one shared lag, one shared schedule).
|
||||
if (linkedEv.fired) {
|
||||
applySplice(linkedEv);
|
||||
} else {
|
||||
// Self-healing fallback (review rider): the master not firing normally means this
|
||||
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
|
||||
// renders a mono block mid-note, this follower channel is skipped for that block
|
||||
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
|
||||
// only the `if (linkedEv.fired)` path above, could never resync. So check this
|
||||
// follower's OWN tap distance against the safe band and splice via its own search
|
||||
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
|
||||
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
|
||||
// never triggers: the master's trigger fires first and this whole `if` is false.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
while (d >= len) d -= len;
|
||||
if (d <= static_cast<double>(dLow_)) {
|
||||
splice(+window_, d);
|
||||
} else if (d >= static_cast<double>(dHigh_)) {
|
||||
splice(-window_, d);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
||||
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
|
||||
// unity the delay is frozen at window/2 and neither trigger ever fires.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
while (d >= len) d -= len;
|
||||
if (d <= static_cast<double>(dLow_)) {
|
||||
splice(+window_, d);
|
||||
} else if (d >= static_cast<double>(dHigh_)) {
|
||||
splice(-window_, d);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
|
||||
// tap(s) by the shift ratio.
|
||||
if (!tailFrozen_) {
|
||||
++writePos_;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
}
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
posA_ += ratio_;
|
||||
while (posA_ >= len) posA_ -= len;
|
||||
if (fading_) {
|
||||
posB_ += ratio_;
|
||||
while (posB_ >= len) posB_ -= len;
|
||||
}
|
||||
|
||||
return static_cast<AudioSample>(out);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,230 @@
|
||||
#pragma once
|
||||
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
||||
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
|
||||
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
|
||||
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
|
||||
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
|
||||
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
|
||||
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
|
||||
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
||||
//
|
||||
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
|
||||
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
|
||||
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
|
||||
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
|
||||
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
|
||||
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
|
||||
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
|
||||
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
|
||||
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
|
||||
// lands on an integer period count and the output stays a single shifted tone.
|
||||
//
|
||||
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
||||
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
||||
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
||||
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
||||
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
||||
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
||||
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
|
||||
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
|
||||
// the SHELL, never in the pure core.
|
||||
//
|
||||
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
|
||||
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
|
||||
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
|
||||
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
|
||||
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
|
||||
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
|
||||
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
|
||||
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
|
||||
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
|
||||
// land in unwritten silence.
|
||||
//
|
||||
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
|
||||
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
|
||||
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
|
||||
// Splices landing in or referenced against it were unalignable, so the tap alternated
|
||||
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
|
||||
// displaced real ring history (the DAW report: periodic troughs "almost like ring
|
||||
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
|
||||
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
|
||||
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
|
||||
// own note end. See freezeTail() below.
|
||||
//
|
||||
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
|
||||
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
|
||||
// wav_trim do the same).
|
||||
//
|
||||
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
|
||||
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
|
||||
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
|
||||
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
|
||||
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
|
||||
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
|
||||
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
|
||||
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
|
||||
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
|
||||
// follower applies exactly this decision instead of running its own search. Both channels
|
||||
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
|
||||
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
|
||||
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
|
||||
struct SpliceEvent {
|
||||
bool fired = false; // a splice was scheduled on this frame
|
||||
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
|
||||
std::int64_t lag = 0; // correlation best integer lag
|
||||
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
||||
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
||||
};
|
||||
|
||||
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
|
||||
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
|
||||
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
|
||||
//
|
||||
// The default-constructed shifter is INERT: with no configure() it passes input through
|
||||
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
||||
// byte-identical to the pre-S16 engine.
|
||||
class PitchShifter {
|
||||
public:
|
||||
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
|
||||
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
|
||||
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
|
||||
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
|
||||
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
|
||||
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
|
||||
void configure(std::int64_t windowFrames);
|
||||
|
||||
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
|
||||
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
|
||||
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
|
||||
// structural latency at every ratio, and splices always have `count` frames of real
|
||||
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
|
||||
// When the PLAYABLE source is shorter than one window, prime only the real span and call
|
||||
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
|
||||
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
|
||||
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
|
||||
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
|
||||
// The current shift ratio is left untouched.
|
||||
void prime(const AudioSample* src, std::int64_t count);
|
||||
|
||||
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
|
||||
// declare that window of silence as valid history. Kept for callers with no access to the
|
||||
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking —
|
||||
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
|
||||
// bit-exact window() delay. No-op when unconfigured.
|
||||
void warm();
|
||||
|
||||
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
||||
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
|
||||
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
|
||||
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
||||
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
||||
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
||||
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
|
||||
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
|
||||
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
|
||||
AudioSample process(AudioSample in);
|
||||
|
||||
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
|
||||
// except the splice decision is NOT computed here — when `master.fired` is true this
|
||||
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
|
||||
// considered. The caller must process the master channel FIRST each frame and pass its
|
||||
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
|
||||
// ring state then advances in lockstep, so the follower's own trigger would have fired
|
||||
// on the same frame anyway; skipping its search only removes the second correlation
|
||||
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
|
||||
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
||||
|
||||
// The splice decision made by the most recent process()/processLinked() call (fired ==
|
||||
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
|
||||
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
||||
|
||||
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
|
||||
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
|
||||
// their input and write nothing, but read, splice, and crossfade exactly as before over
|
||||
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
|
||||
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
|
||||
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
|
||||
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
|
||||
// note end as the plateau displaced real history). With the writer frozen the padding
|
||||
// never enters the ring: every splice stays waveform-aligned against real content and
|
||||
// the output remains a continuous tone — the final <= one window recycles the frozen
|
||||
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
|
||||
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
|
||||
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
|
||||
void freezeTail();
|
||||
|
||||
bool tailFrozen() const { return tailFrozen_; }
|
||||
|
||||
// Reset running state to silence (ring zeroed, heads re-seeded mid-band, fill count zeroed)
|
||||
// WITHOUT reallocating — for voice reuse without a re-configure. Keeps the current window.
|
||||
// Follow with prime() (or warm()) before streaming: a bare reset has no declared history,
|
||||
// so an immediate up-shift would starve its splices.
|
||||
void reset();
|
||||
|
||||
// True once configure() sized a real ring (window > 1). A pass-through shifter is false.
|
||||
bool configured() const { return window_ > 1; }
|
||||
|
||||
std::int64_t window() const { return window_; }
|
||||
|
||||
private:
|
||||
double readTap(double pos) const; // fractional ring read, linear interp
|
||||
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
|
||||
// span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind
|
||||
// the writer (the caller just computed it for the trigger test). Records the decision in
|
||||
// lastSplice_ for a linked follower channel.
|
||||
void splice(std::int64_t nominalJump, double delay);
|
||||
// Apply a master channel's already-computed splice decision verbatim (no search) —
|
||||
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
|
||||
void applySplice(const SpliceEvent& ev);
|
||||
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
||||
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
||||
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
|
||||
|
||||
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
|
||||
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
|
||||
std::int64_t ringLen_ = 0; // ring length (2 * window_): splice + search headroom
|
||||
std::int64_t writePos_ = 0; // integer write head into the ring (source rate)
|
||||
double posA_ = 0.0; // active read tap (advances at the shift ratio)
|
||||
double posB_ = 0.0; // outgoing tap during a splice crossfade
|
||||
bool fading_ = false; // a splice crossfade is in flight
|
||||
std::int64_t fadePos_ = 0; // crossfade progress, [0, fadeLen_)
|
||||
std::int64_t fadeFrames_ = 0; // NOMINAL crossfade length (window_/4)
|
||||
std::int64_t fadeLen_ = 0; // LIVE crossfade length for the in-flight splice —
|
||||
// ratio-scaled at splice time so an up-shift's outgoing
|
||||
// tap can never drain into the writer mid-fade
|
||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
||||
// the reference read forward from the tap stays behind
|
||||
// the writer BY CONSTRUCTION at an up-splice)
|
||||
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
|
||||
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
|
||||
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
|
||||
// + frames streamed, capped at ringLen_). splice() clamps
|
||||
// its up-jump to this so no splice lands in unwritten
|
||||
// silence — the GA2 onset-gap fix.
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
|
||||
// cleared at the top of every frame, set on a splice
|
||||
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
|
||||
// recycles the ring's frozen real tail, splices still
|
||||
// aligned. With the writer parked, a tap drains toward it
|
||||
// at ratio_ (not ratio_-1) per frame — splice() scales the
|
||||
// live fade by that rate.
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,994 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
|
||||
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
|
||||
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
|
||||
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
|
||||
// to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
|
||||
// byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
|
||||
// every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
|
||||
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
|
||||
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
|
||||
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
// Fall through to Decay this frame so no extra unity sample is emitted for a
|
||||
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
|
||||
// negative fade is treated as 0. When both fades together exceed the play length, shrink
|
||||
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
|
||||
// then the fade-in — never letting either go negative or the sum exceed the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
|
||||
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
|
||||
// The offset is fractional (the read head is fractional under repitch), so the ramps are
|
||||
// smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
|
||||
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
|
||||
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
|
||||
// assembles the first window of the upcoming source stream into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
bool Voice::sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
|
||||
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
|
||||
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
|
||||
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
|
||||
// this reference and the new voice's raw output that frame (seedDeclick), so the
|
||||
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
|
||||
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
|
||||
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
|
||||
// (1 − newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
|
||||
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
|
||||
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
|
||||
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
|
||||
// takeover (two steals of this voice with no frame rendered between) must record the
|
||||
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
|
||||
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
|
||||
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
|
||||
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
|
||||
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
|
||||
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
|
||||
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
|
||||
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
|
||||
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
|
||||
//
|
||||
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
|
||||
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
|
||||
// the live sample rate.
|
||||
//
|
||||
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
|
||||
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
|
||||
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
|
||||
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
|
||||
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
|
||||
// stream there). The tap parks on source frame `start`, so the voice speaks on output
|
||||
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
|
||||
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
|
||||
// ring made every early splice jump into zeros). The rings and the prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters (the engine calls it at
|
||||
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
|
||||
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
|
||||
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
|
||||
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
|
||||
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
|
||||
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
|
||||
// up-shifted tap could play it, transposed, before the voice freed), and a
|
||||
// shorter-than-window sample got zero padding declared as valid history (splices
|
||||
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
|
||||
// material). So bound the prime by the same playable span and, when that span is
|
||||
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
|
||||
// machinery then recycles the real short tail, its designed behavior. The sustain-
|
||||
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
|
||||
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
|
||||
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is ALREADY exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
// 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
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
|
||||
if (playMode_ == PlayMode::Trigger) return;
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
|
||||
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
|
||||
// land on the same source frames under either engine's read rate. The voice ALSO frees on
|
||||
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant.
|
||||
// [Rev 1 stored the frozen difference (ref − x₀); if outₙ rose while that residue was
|
||||
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
|
||||
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
|
||||
// both the overshoot hole and the need for a clamp on the stored value.]
|
||||
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
|
||||
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
|
||||
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
|
||||
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
|
||||
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
|
||||
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
|
||||
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
|
||||
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
|
||||
// per frame and scale all channels equally (a voice is one envelope). The head advances by
|
||||
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
|
||||
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
|
||||
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
|
||||
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
|
||||
// trigger envelope also finishes at the same frame count; either latches the voice idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done. Peer path of the
|
||||
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
|
||||
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
|
||||
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
|
||||
// until the ramp floors; with no declick (the common case, and the entire opt-out
|
||||
// baseline) this is byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
||||
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
|
||||
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
|
||||
// (no per-frame transcendental), byte-identical to pre-S16.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and
|
||||
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to
|
||||
// the shift amount, not the read rate — pitch bends, duration unchanged (S16
|
||||
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
|
||||
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
|
||||
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
|
||||
// there is nothing to interpolate). Past the last real frame the shifter's writer is
|
||||
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
|
||||
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
|
||||
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
|
||||
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
|
||||
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
|
||||
// troughs at the splice cadence, growing toward the note end as the plateau displaced
|
||||
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
|
||||
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
|
||||
// still waveform-aligned — a continuous tone through the final window and the release,
|
||||
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
|
||||
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum combing.
|
||||
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
|
||||
// that would advance its heads twice and corrupt the state). Gated on haveR so
|
||||
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
|
||||
// stereo samples, and a stale un-primed ring must not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
|
||||
// this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
|
||||
// the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
|
||||
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [Rev 1 added the frozen difference (ref − x₀) ungated; if outₙ rose while the residue
|
||||
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
|
||||
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
|
||||
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
|
||||
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
|
||||
// sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
|
||||
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
|
||||
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||
// maxVoices — the Poly path sizes the whole pool as before.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||
v.playingSample() == &sample) {
|
||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
|
||||
// The declick opt-in rides every mono restart: start() self-gates it on the voice being
|
||||
// ACTIVE, so a first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||
return;
|
||||
}
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
|
||||
// original velocity. Peer restart site of monoNoteOn's takeover — same declick opt-in
|
||||
// (the fallback also hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
|
||||
// voice being active, so a free-voice start never ramps — only an at-cap steal, which is
|
||||
// the same hard cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,770 @@
|
||||
#pragma once
|
||||
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
|
||||
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
|
||||
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
|
||||
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
|
||||
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
|
||||
// with loop-point-aware sustain.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
|
||||
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
|
||||
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
|
||||
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
|
||||
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
|
||||
//
|
||||
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
|
||||
// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The
|
||||
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
|
||||
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core
|
||||
// re-namespaces in its own split wave (Q-W2v).
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
|
||||
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
|
||||
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
|
||||
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
|
||||
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
|
||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
|
||||
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
|
||||
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
|
||||
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
|
||||
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
|
||||
// current behavior.
|
||||
enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
|
||||
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
|
||||
// the envelope running when a note is taken over while another is held — pitch moves without
|
||||
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
|
||||
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
|
||||
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
|
||||
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
|
||||
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
|
||||
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
|
||||
inline constexpr int kMinVoiceCount = 1;
|
||||
inline constexpr int kMaxVoiceCount = 32;
|
||||
inline constexpr int kDefaultVoiceCount = 16;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
|
||||
// with the rest of the engine machinery; only the value structs need to precede SampleData.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
|
||||
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
};
|
||||
|
||||
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
|
||||
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
|
||||
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
|
||||
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
|
||||
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
|
||||
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
|
||||
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
|
||||
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
|
||||
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
|
||||
};
|
||||
|
||||
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
|
||||
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
|
||||
// so a third curve can join without a signature change.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
|
||||
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
|
||||
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
|
||||
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
|
||||
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
|
||||
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
|
||||
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
|
||||
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
|
||||
// engine" holds for the core's own regression tests (an octave up still halves duration in the
|
||||
// bare engine); the Preserve product default is layered on above at (de)serialization.
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
|
||||
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
|
||||
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
|
||||
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
|
||||
// One knob, resolved at voice allocation.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
|
||||
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
|
||||
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
|
||||
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
|
||||
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
|
||||
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
|
||||
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
|
||||
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
|
||||
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr; // Gate: the AHDSR envelope
|
||||
TriggerParams trigger; // Trigger: %-length + fades
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
|
||||
// govern playback. The shell decodes the on-disk WAV and fills this; the core
|
||||
// never touches a file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
|
||||
// is the "no sustain loop" marker — a held note past the sample end goes silent
|
||||
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
|
||||
struct SampleLoop {
|
||||
bool hasLoop = false;
|
||||
std::int64_t start = 0; // first looped frame (inclusive)
|
||||
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
|
||||
};
|
||||
|
||||
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
|
||||
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
|
||||
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
|
||||
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
|
||||
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
|
||||
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
|
||||
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
|
||||
int sampleRate = 0; // frames per second (for reference; ratio is
|
||||
// note-relative, so rate cancels for repitch).
|
||||
// 0 is explicitly invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
||||
SampleLoop loop; // sustain loop, if any
|
||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
||||
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
|
||||
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
|
||||
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
|
||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
|
||||
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
|
||||
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
|
||||
ZonePlayParams play;
|
||||
|
||||
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
|
||||
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
|
||||
// to at most one zone; a zone names which SampleData to play and the root note to
|
||||
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
|
||||
// sample's own root. Tier-1: several zones, each a key range with its own root.
|
||||
//
|
||||
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
|
||||
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
|
||||
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
|
||||
// resolve() gains the velocity dimension it already receives but currently ignores
|
||||
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
|
||||
// sample within it" shape are already in place — Tier 2 fills in the second step
|
||||
// without changing callers or the voice engine. See the report note.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
|
||||
// the root note to repitch from (defaults to the sample's own root, overridable in
|
||||
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
|
||||
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
|
||||
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
|
||||
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
|
||||
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
|
||||
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
|
||||
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
|
||||
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
|
||||
// unity, a deliberate behavior change from the pre-r10 linear map.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// Result of resolving a (note, velocity). `matched == false` means the note falls in
|
||||
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
|
||||
// resolution. Pure: no host types. Zones are tested first-match in order, so an
|
||||
// earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
|
||||
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
|
||||
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
|
||||
// the note.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// Convenience: build the Tier-0 degenerate keymap — one sample mapped
|
||||
// chromatically across the whole keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
|
||||
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
|
||||
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
|
||||
// far playback pitch tracks the keyboard around the root:
|
||||
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
|
||||
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
|
||||
// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
|
||||
// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
|
||||
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
|
||||
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
|
||||
// the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
|
||||
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
|
||||
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
|
||||
//
|
||||
// Segment math (all linear ramps):
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
|
||||
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
|
||||
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
|
||||
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
|
||||
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
||||
// one value per output frame.
|
||||
double tick();
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
|
||||
// FadeCurve value structs are defined above with the other per-zone params.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
|
||||
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
|
||||
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
|
||||
// output and source frames coincide, but under Varispeed a transposed voice consumes source
|
||||
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
|
||||
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
|
||||
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
|
||||
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
|
||||
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
|
||||
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
|
||||
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
|
||||
// the offset (no internal advance) so it composes with either pitch engine's read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
|
||||
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
|
||||
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
|
||||
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
|
||||
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
// Advance one frame, return this frame's pitch offset in semitones.
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is
|
||||
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope
|
||||
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY
|
||||
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono
|
||||
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a
|
||||
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just
|
||||
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes
|
||||
// it on all of those restart paths when constructed with takeoverDeclick),
|
||||
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
|
||||
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
|
||||
// compensation equal to (reference − that frame's raw new output). The compensation is
|
||||
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
|
||||
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
|
||||
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
|
||||
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
|
||||
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
|
||||
// [Rev 1 decayed the OLD output gated by (1 − newAmp): any restart whose new amplitude was
|
||||
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
|
||||
// and kept the full click. The difference seed has no such hole and needs no gate: when old
|
||||
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
|
||||
// gate defended against is structurally impossible.] OFF by default so the bare core stays
|
||||
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
|
||||
// in for the engine, mirroring the kDefaultPitchEngine layering.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
||||
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
|
||||
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
|
||||
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
|
||||
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
|
||||
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
|
||||
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
|
||||
// thread inside process(). The warm silence pass settles the OLA taps before the first
|
||||
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
|
||||
// is default (Gate + Varispeed + no pitch env).
|
||||
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
|
||||
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
||||
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
||||
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
||||
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
|
||||
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
|
||||
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
|
||||
// the first frame rendered after the restart (see the takeover-declick block above
|
||||
// kDeclickDecay). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
|
||||
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
|
||||
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
|
||||
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
|
||||
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
|
||||
// takeover must restart the voice instead (see MonoTrigger).
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless
|
||||
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
|
||||
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while this voice is producing (or about to produce) sound (including any
|
||||
// declick ring-out tail past the note's playable span).
|
||||
bool active() const { return active_; }
|
||||
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude
|
||||
// envelope has not yet finished. A voice whose note has run to its end but is still
|
||||
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
|
||||
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
|
||||
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
|
||||
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
// The note this voice was started on (for note-off routing). Meaningless if idle.
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter — higher = started earlier relative to others. The voice
|
||||
// engine uses this for its stealing policy (oldest first). Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE —
|
||||
// it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on
|
||||
// frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code
|
||||
// path, uniform onset across the keyboard).
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// The SampleData this voice is playing (nullptr when never started). The engine's mono
|
||||
// legato path compares it against the new note's resolved sample — a same-sample takeover
|
||||
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
||||
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
|
||||
// once at construction so start() — which runs on the audio thread inside process() — never
|
||||
// allocates: start() only prime()s the already-sized rings with the first window of source
|
||||
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
|
||||
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
|
||||
// no-op in the underlying vector.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one
|
||||
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
|
||||
// sample runs out with no loop. The value is already velocity- and
|
||||
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
|
||||
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
|
||||
AudioSample renderFrame();
|
||||
|
||||
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
|
||||
// the read head + envelope by exactly one frame (the same single advance the mono path
|
||||
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
|
||||
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
|
||||
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
|
||||
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
|
||||
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
|
||||
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
|
||||
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
|
||||
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
|
||||
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
|
||||
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
|
||||
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
|
||||
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
|
||||
// the start()-time ring prime.
|
||||
bool sustainLoopUsable() const;
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
|
||||
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
|
||||
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
|
||||
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
|
||||
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
|
||||
//
|
||||
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
|
||||
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source
|
||||
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
|
||||
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
|
||||
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
|
||||
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and
|
||||
// the splice machinery recycles the frozen real tail through the note end (see
|
||||
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
|
||||
// (never touched outside start()).
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
|
||||
// ACTUAL discontinuity — (pre-cut reference − the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly. See the
|
||||
// takeover-declick block above kDeclickDecay.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
|
||||
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
|
||||
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
|
||||
// outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
|
||||
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
|
||||
// starts at 1.0 and decays by
|
||||
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the
|
||||
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
|
||||
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
|
||||
// [Rev 1 stored the frozen difference (ref − x₀); when outₙ rose while that residue
|
||||
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
|
||||
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0.
|
||||
// The whole declick state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
|
||||
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
|
||||
// new note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
|
||||
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
|
||||
// that, the note that has already had the most time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
||||
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
||||
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
|
||||
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
|
||||
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
|
||||
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
|
||||
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
|
||||
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
|
||||
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
||||
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
||||
// are unaffected.
|
||||
//
|
||||
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
|
||||
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
|
||||
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
|
||||
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
|
||||
// the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
|
||||
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
|
||||
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
|
||||
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
|
||||
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
|
||||
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
|
||||
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
|
||||
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
|
||||
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
|
||||
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
|
||||
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
|
||||
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
|
||||
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
|
||||
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||
// this never touches memory it does not own and NEVER allocates). This is the
|
||||
// audio-thread entry point: the VST3 process callback passes the host's own output
|
||||
// channel buffer, so no allocation, resize, or heap traffic happens under process.
|
||||
// Voices that finish mid-block go idle and stop contributing. `out` must point at
|
||||
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
|
||||
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
|
||||
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
|
||||
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
|
||||
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
|
||||
// and stereo render paths are independent output shapes over the SAME voice pool; the active
|
||||
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
|
||||
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
|
||||
// real-time overload after sizing the buffer, so both paths share one mix loop.
|
||||
// Does not clear existing contents — appends, matching the pre-S4 contract the
|
||||
// unit tests rely on.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
|
||||
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
|
||||
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
|
||||
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
|
||||
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
|
||||
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
|
||||
// re-strikes the fallen-back-to note at ITS original velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
|
||||
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
|
||||
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
|
||||
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
|
||||
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
|
||||
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated
|
||||
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on
|
||||
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a
|
||||
// preview is a real voice: it counts against the voice count, can steal / be stolen, and
|
||||
// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation
|
||||
// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2
|
||||
// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing
|
||||
// but a second code path.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,272 @@
|
||||
// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
|
||||
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
|
||||
#include <cmath> // std::fabs
|
||||
#include <utility> // std::move
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
||||
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
||||
|
||||
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
|
||||
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
|
||||
double velPerPixel(const VelocityCurve::Box& box) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return 0.0;
|
||||
return (kVelMax - kVelMin) / static_cast<double>(w);
|
||||
}
|
||||
double ampPerPixel(const VelocityCurve::Box& box) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return 0.0;
|
||||
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
|
||||
}
|
||||
int velToX(const VelocityCurve::Box& box, double velocity) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return box.left;
|
||||
const double frac = (clampVelocity(velocity) - kVelMin) / (kVelMax - kVelMin);
|
||||
return box.left + static_cast<int>(frac * static_cast<double>(w) + 0.5);
|
||||
}
|
||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return box.top;
|
||||
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
|
||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VelocityCurve VelocityCurve::flat() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::linear() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
|
||||
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
|
||||
for (VelocityPoint& p : pts) {
|
||||
p.velocity = clampVelocity(p.velocity);
|
||||
p.amp = clampAmp(p.amp);
|
||||
}
|
||||
std::stable_sort(pts.begin(), pts.end(),
|
||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||
return a.velocity < b.velocity;
|
||||
});
|
||||
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
|
||||
if (pts.size() < 2) return flat();
|
||||
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
|
||||
if (pts.front().velocity > kVelMin) {
|
||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
||||
} else {
|
||||
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
|
||||
}
|
||||
if (pts.back().velocity < kVelMax) {
|
||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
||||
} else {
|
||||
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
|
||||
}
|
||||
VelocityCurve c;
|
||||
c.points_ = std::move(pts);
|
||||
return c;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
|
||||
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
|
||||
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
|
||||
//
|
||||
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
|
||||
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
|
||||
// weighted-harmonic-mean tangent (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
|
||||
// reduces to that common secant — so collinear control points reproduce the straight line to within
|
||||
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
|
||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
|
||||
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
|
||||
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
|
||||
const double w1 = 2.0 * spanNext + spanPrev;
|
||||
const double w2 = spanNext + 2.0 * spanPrev;
|
||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double VelocityCurve::eval(double velocity) const {
|
||||
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
||||
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
|
||||
const double v = clampVelocity(velocity);
|
||||
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
||||
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
||||
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
|
||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||
const VelocityPoint& a = points_[i];
|
||||
const VelocityPoint& b = points_[i + 1];
|
||||
if (v >= a.velocity && v <= b.velocity) {
|
||||
const double span = b.velocity - a.velocity;
|
||||
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
||||
// has zero width so there is no interior to blend.
|
||||
if (span <= 0.0) return clampAmp(b.amp);
|
||||
|
||||
// --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] ---------
|
||||
// Curved (spline) response, not straight lines. The interpolant provably stays within
|
||||
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
|
||||
// control points its tangents reduce to the secant slope — so it reproduces the straight
|
||||
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
|
||||
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
|
||||
const double d = (b.amp - a.amp) / span; // secant of THIS segment
|
||||
|
||||
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
|
||||
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
|
||||
double mA = d;
|
||||
if (i > 0) {
|
||||
const VelocityPoint& prev = points_[i - 1];
|
||||
const double spanPrev = a.velocity - prev.velocity;
|
||||
if (spanPrev > 0.0) {
|
||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||
} else {
|
||||
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
|
||||
}
|
||||
}
|
||||
double mB = d;
|
||||
if (i + 2 < points_.size()) {
|
||||
const VelocityPoint& next = points_[i + 2];
|
||||
const double spanNext = next.velocity - b.velocity;
|
||||
if (spanNext > 0.0) {
|
||||
const double dNext = (next.amp - b.amp) / spanNext;
|
||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||
} else {
|
||||
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
|
||||
}
|
||||
}
|
||||
|
||||
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
|
||||
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
|
||||
// line to within floating-point rounding (~1e-15).
|
||||
const double t = (v - a.velocity) / span;
|
||||
const double t2 = t * t;
|
||||
const double t3 = t2 * t;
|
||||
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
|
||||
const double h10 = t3 - 2.0 * t2 + t;
|
||||
const double h01 = -2.0 * t3 + 3.0 * t2;
|
||||
const double h11 = t3 - t2;
|
||||
const double y = h00 * a.amp + h10 * span * mA + h01 * b.amp + h11 * span * mB;
|
||||
return clampAmp(y);
|
||||
}
|
||||
}
|
||||
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
|
||||
}
|
||||
|
||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
||||
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
|
||||
// duplicate-X point lands immediately after the existing one (a later move can separate them).
|
||||
std::size_t i = 0;
|
||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||
return i;
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
|
||||
if (index >= points_.size()) return VelocityPoint{}; // no-op (out of range)
|
||||
const bool isFirst = (index == 0);
|
||||
const bool isLast = (index + 1 == points_.size());
|
||||
|
||||
double newAmp = clampAmp(amp);
|
||||
double newVel;
|
||||
if (isFirst) {
|
||||
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
|
||||
} else if (isLast) {
|
||||
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
|
||||
} else {
|
||||
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
|
||||
const double lo = points_[index - 1].velocity;
|
||||
const double hi = points_[index + 1].velocity;
|
||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||
}
|
||||
points_[index] = VelocityPoint{newVel, newAmp};
|
||||
return points_[index];
|
||||
}
|
||||
|
||||
bool VelocityCurve::deletePoint(std::size_t index) {
|
||||
if (index >= points_.size()) return false;
|
||||
if (index == 0 || index + 1 == points_.size()) return false; // endpoints are not deletable
|
||||
points_.erase(points_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
return true;
|
||||
}
|
||||
|
||||
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
|
||||
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
|
||||
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
|
||||
// ampToY pins to box.top (amp 1).
|
||||
VelocityPoint p;
|
||||
const int w = std::max(0, box.width);
|
||||
const int h = std::max(0, box.height);
|
||||
p.velocity = (w <= 0)
|
||||
? kVelMin
|
||||
: clampVelocity(kVelMin + static_cast<double>(x - box.left) / static_cast<double>(w) *
|
||||
(kVelMax - kVelMin));
|
||||
p.amp = (h <= 1)
|
||||
? kAmpMax
|
||||
: clampAmp(kAmpMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
|
||||
(kAmpMax - kAmpMin));
|
||||
return p;
|
||||
}
|
||||
|
||||
int VelocityCurve::pointAtPixel(const Box& box, int x, int y) const {
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
const int px = velToX(box, points_[i].velocity);
|
||||
const int py = ampToY(box, points_[i].amp);
|
||||
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels) {
|
||||
VelocityCurve out = grabCurve;
|
||||
if (index >= out.points_.size()) return out; // out of range -> no motion
|
||||
const double velPerPx = velPerPixel(box);
|
||||
const double ampPerPx = ampPerPixel(box);
|
||||
if (velPerPx <= 0.0 || ampPerPx <= 0.0) return out; // degenerate box -> no motion
|
||||
|
||||
const VelocityPoint& grab = grabCurve.points_[index];
|
||||
const double newVel = grab.velocity + static_cast<double>(dxPixels) * velPerPx;
|
||||
// Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp.
|
||||
const double newAmp = grab.amp - static_cast<double>(dyPixels) * ampPerPx;
|
||||
out.movePoint(index, newVel, newAmp); // applies box + neighbour-X + endpoint-pin clamps
|
||||
return out;
|
||||
}
|
||||
|
||||
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
|
||||
if (points_.size() != other.points_.size()) return false;
|
||||
for (std::size_t i = 0; i < points_.size(); ++i) {
|
||||
if (std::fabs(points_[i].velocity - other.points_[i].velocity) > eps) return false;
|
||||
if (std::fabs(points_[i].amp - other.points_[i].amp) > eps) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,171 @@
|
||||
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
|
||||
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
|
||||
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
|
||||
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
|
||||
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
//
|
||||
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
|
||||
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
|
||||
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
|
||||
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
|
||||
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
|
||||
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
|
||||
//
|
||||
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
|
||||
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
|
||||
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
|
||||
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
|
||||
//
|
||||
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
|
||||
// couldn't handle:
|
||||
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
|
||||
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
|
||||
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
|
||||
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
|
||||
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
|
||||
// refuses to remove them, and the constructors seed them.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
|
||||
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
|
||||
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
|
||||
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
|
||||
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
|
||||
// role, but one layer lower, so the coupling stays out of the engine core.
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||
inline constexpr double kVelMin = 0.0;
|
||||
inline constexpr double kVelMax = 127.0;
|
||||
inline constexpr double kAmpMin = 0.0;
|
||||
inline constexpr double kAmpMax = 1.0;
|
||||
|
||||
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
|
||||
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
|
||||
// build curves through the named constructors / addPoint rather than pushing raw points.
|
||||
struct VelocityPoint {
|
||||
double velocity = 0.0; // X, [0,127]
|
||||
double amp = 0.0; // Y, [0,1]
|
||||
};
|
||||
|
||||
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
|
||||
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
|
||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||
|
||||
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
||||
// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine
|
||||
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
|
||||
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
|
||||
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
|
||||
// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to
|
||||
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
|
||||
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
|
||||
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
||||
class VelocityCurve {
|
||||
public:
|
||||
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
||||
static VelocityCurve flat();
|
||||
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
|
||||
// and as the Option-B seed; NOT the default (see R10-F1).
|
||||
static VelocityCurve linear();
|
||||
|
||||
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
|
||||
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
|
||||
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
|
||||
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
|
||||
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
|
||||
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
|
||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
||||
|
||||
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
|
||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||
std::size_t size() const { return points_.size(); }
|
||||
|
||||
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
||||
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
||||
// points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a
|
||||
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
|
||||
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
|
||||
// knots. Single-valued / monotonic in X.
|
||||
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
|
||||
// unity); a ONE-point curve returns that point's amp.
|
||||
double eval(double velocity) const;
|
||||
|
||||
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
||||
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
|
||||
// index of the inserted point. A new point at a velocity that duplicates an existing one is
|
||||
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
|
||||
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
|
||||
std::size_t addPoint(double velocity, double amp);
|
||||
|
||||
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
|
||||
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
|
||||
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
|
||||
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
|
||||
// resulting point.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||
|
||||
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
|
||||
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
|
||||
// point was removed.
|
||||
bool deletePoint(std::size_t index);
|
||||
|
||||
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
|
||||
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
|
||||
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
|
||||
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
|
||||
// stays free of editor-layout types — see the header preamble.
|
||||
struct Box {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
|
||||
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
|
||||
// for determinism (mirror of nodeAtPoint).
|
||||
int pointAtPixel(const Box& box, int x, int y) const;
|
||||
|
||||
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
|
||||
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
|
||||
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
|
||||
struct CurvePixel {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||
|
||||
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
|
||||
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
|
||||
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
|
||||
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
|
||||
// pixelFromPoint's degenerate collapse.
|
||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||
|
||||
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
|
||||
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
|
||||
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
|
||||
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
|
||||
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
|
||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels);
|
||||
|
||||
// Equality (for tests + round-trip assertions): same point count + each point equal within a
|
||||
// tight epsilon.
|
||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||
|
||||
private:
|
||||
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
|
||||
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
|
||||
// preserve it.
|
||||
std::vector<VelocityPoint> points_;
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,63 @@
|
||||
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
|
||||
|
||||
#include "core/instrument/map/bank_sync.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
|
||||
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
|
||||
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
|
||||
// reject to the absent default (0); the guarded accumulate means a
|
||||
// pathologically long digit run can never wrap into a bogus small value.
|
||||
std::int64_t value = 0;
|
||||
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string formatBankGeneration(std::int64_t generation) {
|
||||
// Non-negative decimal; a negative (should never be produced by the writer) formats as
|
||||
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
|
||||
// stays in the >= 0 domain by construction.
|
||||
return std::to_string(generation);
|
||||
}
|
||||
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current) {
|
||||
return current != seen;
|
||||
}
|
||||
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget) {
|
||||
AssignConsumeDecision d;
|
||||
d.consumedGeneration = lastConsumed; // default: nothing changes
|
||||
|
||||
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
|
||||
if (!request) return d;
|
||||
if (request->generation <= lastConsumed) return d;
|
||||
|
||||
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
|
||||
// advance the marker (stay eligible if focus later lands here). No thundering herd.
|
||||
if (!isFocusedTarget) return d;
|
||||
|
||||
// The request is new AND we are the target: it will be consumed-as-seen either way, so
|
||||
// advance the marker to its generation so it is never re-evaluated.
|
||||
d.consumedGeneration = request->generation;
|
||||
|
||||
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
|
||||
// advanced above, but no selection change.
|
||||
if (!resolves) return d;
|
||||
|
||||
// Rule 4: new, target, resolvable -> apply the selection.
|
||||
d.apply = true;
|
||||
d.bankId = request->bankId;
|
||||
d.sampleId = request->sampleId;
|
||||
return d;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
|
||||
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
|
||||
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
|
||||
// host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
|
||||
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
|
||||
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
|
||||
// read crosses the bridge in the shell; every DECISION after — parse the generation
|
||||
// stamp, decide whether it differs from what we last saw, decide whether a decoded
|
||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
||||
//
|
||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
||||
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
|
||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "core/wire/assignment_request.h" // AssignmentRequest (the decoded request this consumes)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using wire::AssignmentRequest;
|
||||
|
||||
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
|
||||
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
|
||||
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
|
||||
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
|
||||
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
||||
|
||||
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
|
||||
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
|
||||
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
|
||||
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
|
||||
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
|
||||
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
|
||||
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
|
||||
// poll once the value is clean).
|
||||
std::int64_t parseBankGeneration(const std::string& raw);
|
||||
|
||||
// Format a bank-generation counter for the ext-state stamp. The inverse of
|
||||
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
|
||||
// the stamp is byte-stable across writes of the same value.
|
||||
std::string formatBankGeneration(std::int64_t generation);
|
||||
|
||||
// Has the bank generation changed since the reader last saw `seen`? True when `current`
|
||||
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
|
||||
// an increase): the writer is monotonic, but a project switch or reload can legitimately
|
||||
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
|
||||
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
|
||||
// first-bump refresh the spec requires).
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
||||
|
||||
// The verdict of the S8 assignment-request consume decision (below). A pure value the
|
||||
// processor shell acts on: apply the selection (or not) and advance the consumed marker
|
||||
// (or not). Distinct booleans because the two are NOT the same event — a request may be
|
||||
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
|
||||
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
|
||||
// every poll.
|
||||
struct AssignConsumeDecision {
|
||||
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
||||
std::string bankId; // the request's bank (valid only when apply)
|
||||
std::string sampleId; // the request's sample (valid only when apply)
|
||||
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
||||
};
|
||||
|
||||
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
|
||||
//
|
||||
// `request` — the decoded assignment request (nullopt when the assign_request key
|
||||
// is absent / malformed — nothing pending).
|
||||
// `lastConsumed` — the generation this instance last consumed (persisted in component
|
||||
// state so a re-open does not re-apply a request the user already got,
|
||||
// then manually changed away from). Defaults to 0 for a fresh instance.
|
||||
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
|
||||
// sample RIGHT NOW (the shell computed this against the live bank blob).
|
||||
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
|
||||
// thundering-herd policy (e.g. only the focused-editor instance applies).
|
||||
// The shell passes true when this instance should act; false suppresses
|
||||
// consumption entirely so a non-target instance neither applies nor
|
||||
// advances its marker (it stays eligible if it later becomes the target).
|
||||
//
|
||||
// RULES (all pure, order matters):
|
||||
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
|
||||
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
|
||||
// request's generation, so it is not re-applied.)
|
||||
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
|
||||
// apply and do NOT advance the marker — a non-target instance must stay able to consume
|
||||
// the request if focus later lands on it. (No thundering herd: only the target acts.)
|
||||
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
|
||||
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
|
||||
// but DO advance the marker to the request's generation so a stale/unresolvable request
|
||||
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
|
||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
||||
// advance the marker to the request's generation.
|
||||
//
|
||||
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
|
||||
// consumedGeneration into component state when it advanced.
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,16 @@
|
||||
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
|
||||
|
||||
#include "core/instrument/map/bridge_marshal.h"
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer) {
|
||||
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
|
||||
// both the return AND the buffer: a caller that reused a dirty buffer must not
|
||||
// surface stale bytes as a value when the API reported nothing.
|
||||
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,37 @@
|
||||
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
||||
// NO VST3, NO REAPER types at the boundary.
|
||||
//
|
||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
||||
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
||||
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
||||
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
|
||||
// of a REAPER-facing shell.
|
||||
//
|
||||
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
|
||||
// stand-in until the instrument could parse the bank properly. S4 retired it: the
|
||||
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
|
||||
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
|
||||
// is back to its one honest job — the API-return decode.
|
||||
//
|
||||
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
||||
// -- returns the length written (0 when the key is absent).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
|
||||
// the key) and the buffer it filled. Returns the value only when the API reported a
|
||||
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
|
||||
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
|
||||
//
|
||||
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
|
||||
// wrote (already truncated to the C string by the caller).
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,113 @@
|
||||
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
|
||||
|
||||
#include "core/instrument/map/note_entry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
namespace {
|
||||
char asciiUpper(char c) {
|
||||
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
std::size_t a = 0;
|
||||
std::size_t b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
int clampNote(long long n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > 127) return 127;
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
||||
int letterSemitone(char up) {
|
||||
switch (up) {
|
||||
case 'C': return 0;
|
||||
case 'D': return 2;
|
||||
case 'E': return 4;
|
||||
case 'F': return 5;
|
||||
case 'G': return 7;
|
||||
case 'A': return 9;
|
||||
case 'B': return 11;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
|
||||
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
|
||||
std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
const int base = letterSemitone(asciiUpper(s[i]));
|
||||
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||
++i;
|
||||
int semitone = base;
|
||||
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
|
||||
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
|
||||
// an accidental. '#' raises, 'b'/'B' lowers.
|
||||
if (s[i] == '#') ++semitone;
|
||||
else --semitone;
|
||||
++i;
|
||||
}
|
||||
// The octave: an optional sign then digits, running to the end.
|
||||
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
int octave = 0;
|
||||
bool anyDigit = false;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
octave = octave * 10 + (s[i] - '0');
|
||||
anyDigit = true;
|
||||
}
|
||||
if (!anyDigit) return std::nullopt;
|
||||
if (neg) octave = -octave;
|
||||
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
||||
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
std::optional<int> parseInteger(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (s[i] == '+' || s[i] == '-') {
|
||||
neg = (s[i] == '-');
|
||||
++i;
|
||||
}
|
||||
if (i >= s.size()) return std::nullopt;
|
||||
long long v = 0;
|
||||
for (; i < s.size(); ++i) {
|
||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
||||
v = v * 10 + (s[i] - '0');
|
||||
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
||||
}
|
||||
if (neg) v = -v;
|
||||
return clampNote(v);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<int> parseNoteEntry(const std::string& text) {
|
||||
const std::string s = trim(text);
|
||||
if (s.empty()) return std::nullopt;
|
||||
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
||||
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
||||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
||||
if (auto n = parseInteger(s)) return n;
|
||||
}
|
||||
return parseNoteName(s);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,33 @@
|
||||
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
|
||||
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
|
||||
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
|
||||
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
|
||||
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
|
||||
//
|
||||
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
|
||||
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
|
||||
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
|
||||
// note [0,127] and commits via the same off-thread reload as every other edit.
|
||||
//
|
||||
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
|
||||
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
|
||||
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
|
||||
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
|
||||
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
|
||||
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
|
||||
// nullopt (the shell keeps the old value + may flash the field).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
|
||||
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
|
||||
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
|
||||
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
|
||||
std::optional<int> parseNoteEntry(const std::string& text);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,972 @@
|
||||
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
|
||||
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <cassert> // assert
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using instrument::engine::masterGainMaxLinear;
|
||||
|
||||
namespace {
|
||||
|
||||
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
|
||||
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
|
||||
// SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||
SampleLoop loopFromSample(const Sample& s) {
|
||||
SampleLoop out;
|
||||
if (s.loop) {
|
||||
out.hasLoop = true;
|
||||
out.start = s.loop->start;
|
||||
out.end = s.loop->end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
|
||||
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
|
||||
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
|
||||
SelectedSample distill(const Sample& s) {
|
||||
SelectedSample out;
|
||||
out.relativePath = s.relativePath;
|
||||
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
||||
out.loop = loopFromSample(s);
|
||||
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
|
||||
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
|
||||
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
|
||||
// Shared so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
// Effective root: override beats intrinsic (distill already defaulted an empty
|
||||
// intrinsic to middle C).
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
|
||||
// carried straight through and applied at play time.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
|
||||
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
|
||||
// resolves them to frames.
|
||||
rz.play = z.play;
|
||||
return rz;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId) {
|
||||
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
|
||||
// circuit before parsing — no stored id resolves to nothing to play by design.
|
||||
if (sampleId.empty()) return std::nullopt;
|
||||
if (banksJson.empty()) return std::nullopt;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||
|
||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
||||
// stored id. A sample lives in exactly one bank, so first hit wins.
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(sampleId)) {
|
||||
return distill(*s);
|
||||
}
|
||||
}
|
||||
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
|
||||
// the editor reflects the missing pick with its empty state rather than masking it.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) {
|
||||
if (isExplicit) return current; // user's explicit choice is never fought
|
||||
if (channelCount <= 0) return current; // unknown (0) or pathological -> no change
|
||||
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
}
|
||||
|
||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
|
||||
if (sampleId.empty()) return nullptr;
|
||||
for (const SampleRefEntry& e : refs) {
|
||||
if (e.sampleId == sampleId) return &e.ref;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map) {
|
||||
std::vector<std::string> ids;
|
||||
const auto addUnique = [&ids](const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
for (const std::string& have : ids) {
|
||||
if (have == id) return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
};
|
||||
addUnique(selectionId);
|
||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const std::vector<std::string>& ids) {
|
||||
if (ids.empty() || banksJson.empty()) return;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
|
||||
for (const std::string& id : ids) {
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(id)) { found = s; break; }
|
||||
}
|
||||
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
|
||||
const SelectedSample distilled = distill(*found);
|
||||
bool updated = false;
|
||||
for (SampleRefEntry& e : refs) {
|
||||
if (e.sampleId == id) {
|
||||
e.ref = distilled;
|
||||
e.displayName = found->displayName; // rename sync rides the same refresh
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
|
||||
}
|
||||
}
|
||||
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids) {
|
||||
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
|
||||
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
|
||||
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
|
||||
for (const std::string& id : ids) {
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (b.index.query(id)) return LegacyLiftDecision::Lift;
|
||||
}
|
||||
}
|
||||
// The blob parses and knows none of the referenced ids (or there are none): provably
|
||||
// stale — a lift can never make progress against this bank.
|
||||
return LegacyLiftDecision::Stale;
|
||||
}
|
||||
|
||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
|
||||
refs.erase(std::remove_if(refs.begin(), refs.end(),
|
||||
[&ids](const SampleRefEntry& e) {
|
||||
for (const std::string& id : ids) {
|
||||
if (id == e.sampleId) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
refs.end());
|
||||
}
|
||||
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
||||
std::vector<SampleChoice> out;
|
||||
if (banksJson.empty()) return out;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out;
|
||||
for (const Bank& b : book->banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson) {
|
||||
std::vector<BankChoice> out;
|
||||
if (banksJson.empty()) return out;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out;
|
||||
for (const Bank& b : book->banks()) {
|
||||
out.push_back(BankChoice{b.id, b.displayName});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount) {
|
||||
std::vector<AudioSample> out;
|
||||
if (channelCount <= 0 || interleaved.empty()) return out;
|
||||
const std::size_t stride = static_cast<std::size_t>(channelCount);
|
||||
const std::size_t frames = interleaved.size() / stride;
|
||||
out.resize(frames);
|
||||
const double inv = 1.0 / static_cast<double>(channelCount);
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
double acc = 0.0;
|
||||
const std::size_t base = f * stride;
|
||||
for (std::size_t c = 0; c < stride; ++c) {
|
||||
acc += static_cast<double>(interleaved[base + c]);
|
||||
}
|
||||
out[f] = static_cast<AudioSample>(acc * inv);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which) {
|
||||
std::vector<AudioSample> out;
|
||||
if (channelCount <= 0 || interleaved.empty()) return out;
|
||||
const std::size_t stride = static_cast<std::size_t>(channelCount);
|
||||
// Clamp the requested channel into the source's range: a channel past the last one reads
|
||||
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
|
||||
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
|
||||
if (ch >= stride) ch = stride - 1;
|
||||
const std::size_t frames = interleaved.size() / stride;
|
||||
out.resize(frames);
|
||||
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
||||
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
||||
return out; // framesR stays empty
|
||||
}
|
||||
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
|
||||
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
|
||||
// out-of-range channel request to the last channel, so a mono source yields L == R.
|
||||
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
|
||||
out.framesR = extractChannel(interleaved, sourceChannels, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
||||
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
||||
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
double f = sec * sr;
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
|
||||
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
|
||||
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
|
||||
out.trigger = stored.trigger; // source-frame / fraction, unchanged
|
||||
out.pitchEngine = stored.pitchEngine;
|
||||
out.pitchEnv.enabled = stored.pitchEnv.enabled;
|
||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// Look the id up across every bank (pool + named) — a sample lives in exactly
|
||||
// one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
continue;
|
||||
}
|
||||
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
|
||||
// run the SHARED fold — so the bank path and the refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
|
||||
// zone cleanly + report — the same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||
if (selectedId.empty() || map.zones.empty()) return false;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||
// there, so the map is left exactly as authored.
|
||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||
}
|
||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||
}
|
||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||
map.zones.resize(keptCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
// --- Performance-map instance state (setState/getState) -----------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
|
||||
// two's-complement u64, mirroring the u32 signed-int idiom above).
|
||||
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
|
||||
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
|
||||
}
|
||||
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
|
||||
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
|
||||
std::uint64_t doubleToBits(double d) {
|
||||
std::uint64_t bits;
|
||||
std::memcpy(&bits, &d, sizeof(bits));
|
||||
return bits;
|
||||
}
|
||||
double bitsToDouble(std::uint64_t bits) {
|
||||
double d;
|
||||
std::memcpy(&d, &bits, sizeof(d));
|
||||
return d;
|
||||
}
|
||||
|
||||
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
|
||||
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
|
||||
// blob degrades to a partial/empty parse rather than reading out of bounds.
|
||||
struct ByteReader {
|
||||
const std::vector<std::uint8_t>& bytes;
|
||||
std::size_t pos = 0;
|
||||
bool ok = true;
|
||||
|
||||
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
||||
|
||||
std::uint32_t u32() {
|
||||
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
pos += 4;
|
||||
return v;
|
||||
}
|
||||
std::uint8_t u8() {
|
||||
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
|
||||
return bytes[pos++];
|
||||
}
|
||||
std::string str(std::uint32_t len) {
|
||||
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
|
||||
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
|
||||
pos += len;
|
||||
return s;
|
||||
}
|
||||
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
|
||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||
|
||||
std::uint64_t u64() {
|
||||
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
|
||||
std::uint64_t v = 0;
|
||||
for (int b = 0; b < 8; ++b)
|
||||
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
|
||||
pos += 8;
|
||||
return v;
|
||||
}
|
||||
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
|
||||
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
|
||||
|
||||
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
|
||||
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
|
||||
// as "no marker" and falls through to the (also-guarded) v1 count read.
|
||||
std::uint32_t peekU32() const {
|
||||
if (!ok || pos + 4 > bytes.size()) return 0;
|
||||
return static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
}
|
||||
};
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component blob,
|
||||
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
|
||||
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
|
||||
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
|
||||
// the zone count so any reader can detect the record shape independently of the envelope version
|
||||
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
|
||||
// through EITHER envelope with no envelope bump.
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putU32le(out, kZonesFormatMarker);
|
||||
putU32le(out, kZonesPayloadVersion);
|
||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// S11 extension: loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putU64le(out, asU64(z.loopOverride->start));
|
||||
putU64le(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
|
||||
|
||||
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
||||
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
||||
// fraction. Order matches the header's v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putU64le(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putU32le(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putU64le(out, doubleToBits(p.velocity));
|
||||
putU64le(out, doubleToBits(p.amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
|
||||
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
|
||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
||||
// keeps the zones that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
|
||||
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
|
||||
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
||||
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
|
||||
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
|
||||
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
|
||||
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
|
||||
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
|
||||
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
|
||||
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
|
||||
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail — the loop still
|
||||
// stops on r.ok, this only caps the speculative reserve.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
|
||||
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Combined component state (v3, S10) --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kComponentStateVersion);
|
||||
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
|
||||
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
||||
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
|
||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
||||
putU64le(out, asU64(state.lastConsumedAssignGeneration));
|
||||
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
||||
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
||||
out.push_back(state.previewVelocity);
|
||||
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
|
||||
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
|
||||
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
|
||||
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
|
||||
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
|
||||
: state.voiceCount;
|
||||
out.push_back(static_cast<std::uint8_t>(vc));
|
||||
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
|
||||
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
|
||||
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
|
||||
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
|
||||
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
|
||||
// negative falls back to unity; above the +24 dB cap clamps to the cap.
|
||||
{
|
||||
double g = state.masterGainLinear;
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
if (!std::isfinite(g) || g < 0.0) g = 1.0;
|
||||
if (g > maxLin) g = maxLin;
|
||||
putU64le(out, doubleToBits(g));
|
||||
}
|
||||
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
|
||||
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
|
||||
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
|
||||
// channel count); 1 = the user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
|
||||
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
|
||||
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
||||
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
|
||||
// written), channelCount, displayName (length-prefixed; display-only).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
|
||||
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
|
||||
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
|
||||
putU64le(out, asU64(e.ref.loop.start));
|
||||
putU64le(out, asU64(e.ref.loop.end));
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putU32le(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
|
||||
// zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono (pre-S7)
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
||||
// the id length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
||||
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
|
||||
// defaults to 0, so a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
|
||||
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
|
||||
// pre-S-VIEW-4 instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
out.lastConsumedAssignGeneration = r.i64();
|
||||
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
|
||||
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
|
||||
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
|
||||
// instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
out.lastConsumedAssignGeneration = r.i64();
|
||||
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
|
||||
const std::uint8_t previewVel = r.u8();
|
||||
if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds)
|
||||
// Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a
|
||||
// corrupt blob) falls back to the mid default rather than silencing the preview trigger.
|
||||
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
||||
? previewVel
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
|
||||
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
const std::uint8_t mt = r.u8();
|
||||
if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold)
|
||||
// Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent
|
||||
// for a corrupt blob) rather than clamping to an edge the user never chose.
|
||||
out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount)
|
||||
? static_cast<int>(vc)
|
||||
: kDefaultVoiceCount;
|
||||
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
}
|
||||
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
|
||||
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
|
||||
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
// mode/marker/velocity/voice fields from above; unity holds
|
||||
out.masterGainLinear =
|
||||
(std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9))
|
||||
? g
|
||||
: 1.0;
|
||||
}
|
||||
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
|
||||
// un-touched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
}
|
||||
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
|
||||
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
|
||||
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
|
||||
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
SampleRefEntry e;
|
||||
const std::uint32_t refIdLen = r.u32();
|
||||
e.sampleId = r.str(refIdLen);
|
||||
const std::uint32_t pathLen = r.u32();
|
||||
e.ref.relativePath = r.str(pathLen);
|
||||
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
|
||||
// corrupt field must degrade to the field's default, never poison playback —
|
||||
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
|
||||
// to the middle-C default distill() uses; a negative channel count falls back
|
||||
// to 0 = unknown (the GA auto-default then skips it).
|
||||
const std::int32_t root = r.i32();
|
||||
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
|
||||
e.ref.loop.hasLoop = (r.u8() != 0);
|
||||
e.ref.loop.start = r.i64();
|
||||
e.ref.loop.end = r.i64();
|
||||
const std::int32_t channels = r.i32();
|
||||
e.ref.channelCount = channels >= 0 ? channels : 0;
|
||||
const std::uint32_t nameLen = r.u32();
|
||||
e.displayName = r.str(nameLen);
|
||||
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
|
||||
out.sampleRefs.push_back(std::move(e));
|
||||
}
|
||||
if (!r.ok) return out;
|
||||
}
|
||||
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
|
||||
// EMPTY default holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||
}
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
const std::uint32_t v = kSelectionStateVersion;
|
||||
out[0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < 4) return {}; // no version tag -> no selection
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24);
|
||||
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
|
||||
bytes.size() - 4);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,714 @@
|
||||
#pragma once
|
||||
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
|
||||
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
|
||||
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
|
||||
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
|
||||
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
|
||||
// arithmetic out of a host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
|
||||
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
|
||||
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
|
||||
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
|
||||
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
|
||||
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
|
||||
// Keymap — is pure and unit-tested here.
|
||||
//
|
||||
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
|
||||
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
|
||||
// SampleData it produces). All three are pure; this stays pure.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map
|
||||
// re-namespaces in its own split wave (Q-W2v).
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// The bank sample this instance is bound to, distilled from the live "banks" blob:
|
||||
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
|
||||
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
|
||||
struct SelectedSample {
|
||||
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
|
||||
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
|
||||
// bank entries) — the GA channel-mode auto-default skips it
|
||||
};
|
||||
|
||||
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
|
||||
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
|
||||
// project). `sampleId` is this instance's stored selection.
|
||||
//
|
||||
// Precedence, all pure:
|
||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
||||
// * sampleId empty -> nullopt (NO selection -> silence)
|
||||
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
|
||||
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
|
||||
// the editor returns to the empty state)
|
||||
//
|
||||
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
|
||||
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
|
||||
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
|
||||
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
|
||||
// nullopt rather than silently substituting a different sample — the editor reflects the
|
||||
// missing selection with its "pick a capture" empty state instead of masking it.
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId);
|
||||
|
||||
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
|
||||
// instance's current mode, and whether the user has explicitly toggled the mode, return
|
||||
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
|
||||
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
|
||||
// * isExplicit == true -> current (user's choice stands)
|
||||
// * channelCount == 0 -> current (unknown, skip)
|
||||
// * channelCount >= 2 -> Stereo
|
||||
// * channelCount == 1 -> Mono
|
||||
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
||||
|
||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||
//
|
||||
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
|
||||
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
|
||||
// its OWN component state, a small table of everything it needs to PLAY each referenced
|
||||
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
|
||||
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
|
||||
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
|
||||
// refreshes this table opportunistically when readable (recapture/root edits stay live),
|
||||
// never a runtime lifeline.
|
||||
//
|
||||
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
|
||||
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
|
||||
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
|
||||
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
|
||||
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
|
||||
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
|
||||
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
|
||||
// fallback); never consulted by resolution. Empty for a table written before the field
|
||||
// existed in-session (it back-fills on the next bank refresh).
|
||||
std::string displayName;
|
||||
};
|
||||
using SampleRefs = std::vector<SampleRefEntry>;
|
||||
|
||||
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||
|
||||
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
||||
// sampleId, de-duplicated, selection first then map order.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
|
||||
// distillation selectSample performs), copying the bank display name alongside the decode
|
||||
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
|
||||
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
|
||||
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
|
||||
// instance references?
|
||||
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
|
||||
// project's ext-state may simply not have parsed).
|
||||
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
|
||||
// refs table then goes non-empty and the lift never re-fires).
|
||||
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
|
||||
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
|
||||
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
|
||||
enum class LegacyLiftDecision { Retry, Lift, Stale };
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
|
||||
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
||||
|
||||
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
||||
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
||||
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
||||
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
|
||||
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
|
||||
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
|
||||
// parse — the UI never parses JSON itself.
|
||||
//
|
||||
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
|
||||
// badge shows "root: —" / no root, never a guessed value).
|
||||
// - key: the optional human musical key label ("F#m"), when the bank set it.
|
||||
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
|
||||
struct SampleChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
std::optional<int> rootNote;
|
||||
std::optional<std::string> key;
|
||||
std::string bankId;
|
||||
};
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson);
|
||||
|
||||
// One bank the filter tab strip offers: its stable id + display name, in ordinal order
|
||||
// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty /
|
||||
// malformed blob. Pure projection over the shared parse.
|
||||
struct BankChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
|
||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
||||
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
|
||||
// their source channel count, so a stereo (or N-channel) capture is folded to a single
|
||||
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
|
||||
// the least-surprising, no-clip default — a centered mono source stays unity, and a
|
||||
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
|
||||
// in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
|
||||
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
|
||||
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
|
||||
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
//
|
||||
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
||||
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
||||
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
||||
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
||||
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
||||
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
||||
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
||||
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
double attackSeconds = 0.003; // tier-0 default
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060; // tier-0 default
|
||||
};
|
||||
|
||||
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
|
||||
struct PitchEnvSeconds {
|
||||
bool enabled = false;
|
||||
double attackSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
||||
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
||||
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
||||
struct ZonePlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
||||
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
||||
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
|
||||
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
|
||||
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
|
||||
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
|
||||
// pair never half-plays. `sampleRate` is the WAV's rate.
|
||||
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
|
||||
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
|
||||
// picked single capture plays under the same default engine as a zone would. This function
|
||||
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. It is a
|
||||
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
|
||||
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
|
||||
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
|
||||
//
|
||||
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
|
||||
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
|
||||
// stitches the decoded frames + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
||||
//
|
||||
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
|
||||
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
|
||||
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
|
||||
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
|
||||
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
|
||||
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
|
||||
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
|
||||
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
|
||||
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
|
||||
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
|
||||
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
|
||||
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
|
||||
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
|
||||
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
|
||||
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
|
||||
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
|
||||
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
|
||||
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
||||
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
||||
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
||||
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
||||
ZonePlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
|
||||
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
|
||||
// zone simply takes the contested keys; documented, deterministic).
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
|
||||
//
|
||||
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||
// control edit (ensureSampleZone). Loading a different sample used to change only the
|
||||
// selection id, leaving the previous sample's full-range zone in the map — and since zone
|
||||
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the
|
||||
// engine kept playing the old sample while the editor drew the new one's zone (matched by
|
||||
// sampleId, order-blind). This function is called at every selection-change site so the zone
|
||||
// the editor draws is the zone the engine plays.
|
||||
//
|
||||
// Rules (pure, order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view
|
||||
// authorship; first-match order is load-bearing there — untouched, false. The Sample
|
||||
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent.
|
||||
// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the
|
||||
// first zone bound to `selectedId` (the selection's own params are not reset); drop
|
||||
// the rest. A selection with no zone yet empties the map (the shell then plays the
|
||||
// selection via the Tier-0 fast path with product defaults).
|
||||
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
||||
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
||||
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
||||
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
|
||||
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
|
||||
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
|
||||
// for the whole map — and its id is reported here so the editor can flag/prune it).
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
|
||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
|
||||
//
|
||||
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
|
||||
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
|
||||
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
|
||||
// foldZone, so the drift test is what keeps the shared fold honest.
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
|
||||
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
|
||||
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
|
||||
// is dropped + reported (same stale-id shape as the bank path). Pure.
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
||||
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
||||
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
|
||||
// is preserved so first-match overlap resolution matches the map's authored order. A zone
|
||||
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
|
||||
// map). Empty zones in -> empty Keymap (silence).
|
||||
struct DecodedZonePcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
|
||||
// receive the WAV's real rate before use.
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
|
||||
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
|
||||
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (the existing policy: average all source
|
||||
// channels). framesR EMPTY. A mono or stereo source both collapse.
|
||||
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
|
||||
// takes channels 0 and 1 (documented; the sampler's stereo image is
|
||||
// the first two channels — no surround fold).
|
||||
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
|
||||
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
||||
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
||||
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
||||
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
|
||||
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
|
||||
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
|
||||
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
|
||||
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
||||
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
||||
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
|
||||
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
||||
//
|
||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
||||
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
|
||||
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
||||
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
|
||||
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
|
||||
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
|
||||
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
||||
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
||||
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
||||
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
||||
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
||||
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
|
||||
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
|
||||
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
||||
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
|
||||
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
|
||||
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
|
||||
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
|
||||
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
||||
// older blob lifting to these plays byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
|
||||
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
|
||||
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
|
||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
||||
double masterGainLinear = 1.0;
|
||||
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
||||
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
||||
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
|
||||
std::string instanceGuid;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
|
||||
// after the refs table). Mirrors the v10/v9/… series so the version branches in
|
||||
// deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
|
||||
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
||||
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
|
||||
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
|
||||
// lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
||||
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
|
||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
|
||||
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
|
||||
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
|
||||
// above so already-saved instances restore cleanly.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
// The selected-sample id serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
|
||||
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,27 @@
|
||||
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
|
||||
|
||||
#include "core/instrument/map/trigger_seam.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame) {
|
||||
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame);
|
||||
if (postStart <= 0 || lengthFraction <= 0.0) return 0;
|
||||
return static_cast<std::int64_t>(lengthFraction * static_cast<double>(postStart) + 0.5);
|
||||
}
|
||||
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0.0;
|
||||
return static_cast<double>(fadeFrames) / static_cast<double>(playLength);
|
||||
}
|
||||
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) {
|
||||
if (playLength <= 0) return 0;
|
||||
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,51 @@
|
||||
// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
|
||||
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
//
|
||||
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
|
||||
// of Trigger fade lengths:
|
||||
//
|
||||
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
|
||||
// that anchor directly to the voice's source-timeline read pointer.
|
||||
//
|
||||
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
|
||||
// span, where the played span is:
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
|
||||
// the engine stores frames so the voice advances correctly at the live rate.
|
||||
//
|
||||
// This module owns the one shared formula so the pack (frames->fractions) and unpack
|
||||
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
|
||||
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
|
||||
//
|
||||
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
|
||||
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
|
||||
// invariant (a drag can never produce a value a slider couldn't reach).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// The source-frame length of the Trigger played span:
|
||||
// postStart = max(0, frameCount - startFrame)
|
||||
// playLength = round(lengthFraction * postStart)
|
||||
// `frameCount` is the total decoded sample length in source frames.
|
||||
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
|
||||
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
|
||||
// Returns 0 when postStart == 0 or lengthFraction <= 0.
|
||||
std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame);
|
||||
|
||||
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
|
||||
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
|
||||
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
|
||||
// stays in envelope_edit, not here.
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
||||
|
||||
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
|
||||
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -0,0 +1,158 @@
|
||||
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
|
||||
// capture_browser. No host types; only the shared Rect + BrowserLayout.
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
// The minimum thumb height so a very long bank still yields a grabbable thumb.
|
||||
constexpr int kMinThumbHeight = 20;
|
||||
|
||||
char asciiLower(char c) {
|
||||
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
|
||||
if (cardCount <= 0) return 0;
|
||||
const int columns = (std::max)(1, layout.columns);
|
||||
const int rows = (cardCount + columns - 1) / columns; // ceil
|
||||
return rows * kBrowserCardHeight;
|
||||
}
|
||||
|
||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height);
|
||||
return (std::max)(0, content - gridH);
|
||||
}
|
||||
|
||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset) {
|
||||
const int maxOff = scrollMaxOffset(layout, cardCount);
|
||||
if (proposedOffset < 0) return 0;
|
||||
if (proposedOffset > maxOff) return maxOff;
|
||||
return proposedOffset;
|
||||
}
|
||||
|
||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset) {
|
||||
VisibleRange vr;
|
||||
if (cardCount <= 0) return vr;
|
||||
const int columns = (std::max)(1, layout.columns);
|
||||
const int gridH = (std::max)(0, layout.grid.height);
|
||||
if (gridH <= 0 || kBrowserCardHeight <= 0) {
|
||||
vr.first = 0;
|
||||
vr.last = 0;
|
||||
return vr;
|
||||
}
|
||||
if (offset < 0) offset = 0;
|
||||
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
|
||||
// partially scrolled off the top still draws (its lower part is visible).
|
||||
const int firstRow = offset / kBrowserCardHeight;
|
||||
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
|
||||
// the exclusive end. A row straddling the bottom edge still draws.
|
||||
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
||||
int first = firstRow * columns;
|
||||
int last = lastRow * columns;
|
||||
if (first > cardCount) first = cardCount;
|
||||
if (last > cardCount) last = cardCount;
|
||||
if (last < first) last = first;
|
||||
vr.first = first;
|
||||
vr.last = last;
|
||||
return vr;
|
||||
}
|
||||
|
||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
|
||||
Rect r = cardCellRect(layout, index);
|
||||
if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty
|
||||
return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset);
|
||||
}
|
||||
|
||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height);
|
||||
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
|
||||
const int maxOff = content - gridH;
|
||||
if (offset < 0) offset = 0;
|
||||
if (offset > maxOff) offset = maxOff;
|
||||
|
||||
const int trackRight = layout.grid.right();
|
||||
const int trackLeft = trackRight - kScrollbarWidth;
|
||||
const int trackTop = layout.grid.y;
|
||||
|
||||
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
|
||||
// never taller than the track.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
|
||||
// Thumb top proportional to the offset over the movable track span.
|
||||
const int trackSpan = gridH - thumbH; // >= 0
|
||||
int thumbTop = trackTop;
|
||||
if (maxOff > 0 && trackSpan > 0) {
|
||||
thumbTop = trackTop + static_cast<int>(
|
||||
static_cast<long long>(offset) * trackSpan / maxOff);
|
||||
}
|
||||
return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH);
|
||||
}
|
||||
|
||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
|
||||
int dyPixels) {
|
||||
const int content = scrollContentHeight(layout, cardCount);
|
||||
const int gridH = (std::max)(0, layout.grid.height);
|
||||
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||
|
||||
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
const int trackSpan = gridH - thumbH;
|
||||
if (trackSpan <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||
|
||||
const int maxOff = content - gridH;
|
||||
// A 1px thumb move covers maxOff/trackSpan content px. Round to nearest for symmetry.
|
||||
const long long deltaOffset =
|
||||
(static_cast<long long>(dyPixels) * maxOff + (dyPixels >= 0 ? trackSpan / 2 : -trackSpan / 2)) /
|
||||
trackSpan;
|
||||
const long long proposed = static_cast<long long>(startOffset) + deltaOffset;
|
||||
if (proposed < 0) return 0;
|
||||
if (proposed > maxOff) return maxOff;
|
||||
return static_cast<int>(proposed);
|
||||
}
|
||||
|
||||
Rect searchBoxRect(int w) {
|
||||
if (w <= 0) return Rect{};
|
||||
return Rect::ltrb(0, 0, w, kSearchBoxHeight);
|
||||
}
|
||||
|
||||
bool nameMatchesQuery(const std::string& name, const std::string& query) {
|
||||
if (query.empty()) return true;
|
||||
if (query.size() > name.size()) return false;
|
||||
// Case-insensitive substring scan (ASCII fold). Small strings; a naive scan is fine.
|
||||
for (std::size_t i = 0; i + query.size() <= name.size(); ++i) {
|
||||
bool match = true;
|
||||
for (std::size_t j = 0; j < query.size(); ++j) {
|
||||
if (asciiLower(name[i + j]) != asciiLower(query[j])) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
const std::string& query) {
|
||||
std::vector<int> out;
|
||||
out.reserve(names.size());
|
||||
for (int i = 0; i < static_cast<int>(names.size()); ++i) {
|
||||
if (nameMatchesQuery(names[static_cast<std::size_t>(i)], query))
|
||||
out.push_back(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,107 @@
|
||||
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
|
||||
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
|
||||
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
|
||||
// into these functions.
|
||||
//
|
||||
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
|
||||
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
|
||||
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
|
||||
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
|
||||
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
|
||||
// wheel tick or a thumb drag reaches every card; and
|
||||
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
|
||||
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
|
||||
// then this search narrows within it) + the search-box rect the shell draws the field in.
|
||||
//
|
||||
// It holds NO card data and draws nothing — it knows only the browser layout (from
|
||||
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
|
||||
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
|
||||
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
|
||||
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
|
||||
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
|
||||
inline constexpr int kScrollbarWidth = 10;
|
||||
|
||||
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
|
||||
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
|
||||
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
|
||||
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
|
||||
inline constexpr int kSearchBoxHeight = 22;
|
||||
|
||||
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
|
||||
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
|
||||
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
|
||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
|
||||
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
|
||||
// ceiling for every offset the shell tracks.
|
||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
|
||||
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
|
||||
// or above the first. Pure.
|
||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
||||
|
||||
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
|
||||
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
|
||||
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
|
||||
// yields an empty range (first==last==cardCount). Pure.
|
||||
struct VisibleRange {
|
||||
int first = 0; // first card index drawn (inclusive)
|
||||
int last = 0; // one past the last card index drawn (exclusive)
|
||||
};
|
||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
|
||||
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
|
||||
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
|
||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
||||
|
||||
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
|
||||
// the visible fraction (grid height / content height) and positioned proportional to the
|
||||
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
|
||||
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
|
||||
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
|
||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
|
||||
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
|
||||
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
|
||||
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
|
||||
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
|
||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
||||
|
||||
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
|
||||
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
|
||||
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
|
||||
Rect searchBoxRect(int w);
|
||||
|
||||
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
|
||||
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
|
||||
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
|
||||
// text). Pure — the single match predicate the shell's search narrow is built from.
|
||||
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||
|
||||
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
|
||||
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
|
||||
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
|
||||
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
|
||||
// and applies the bank filter FIRST, then feeds the surviving display names here (search
|
||||
// narrows within the bank). Pure.
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
const std::string& query);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,96 @@
|
||||
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/capture_browser.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
|
||||
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
|
||||
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
|
||||
int tabEdge(int x, int width, int i, int count) {
|
||||
return x + (i * width) / count;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BrowserLayout layoutBrowser(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
BrowserLayout out;
|
||||
const int tabH = std::min(kBrowserTabHeight, ch);
|
||||
out.tabStrip = Rect::ltrb(0, 0, cw, tabH);
|
||||
out.grid = Rect::ltrb(0, tabH, cw, ch);
|
||||
|
||||
const int gridW = std::max(0, out.grid.width);
|
||||
out.columns = std::max(1, gridW / kBrowserCardWidth);
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect cardCellRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int cols = std::max(1, layout.columns);
|
||||
const int col = index % cols;
|
||||
const int row = index / cols;
|
||||
const int left = layout.grid.x + col * kBrowserCardWidth;
|
||||
const int top = layout.grid.y + row * kBrowserCardHeight;
|
||||
return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight);
|
||||
}
|
||||
|
||||
Rect cardContentRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect cell = cardCellRect(layout, index);
|
||||
return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter,
|
||||
cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter);
|
||||
}
|
||||
|
||||
Rect cardThumbnailRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect content = cardContentRect(layout, index);
|
||||
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height));
|
||||
return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH);
|
||||
}
|
||||
|
||||
Rect cardLabelRect(const BrowserLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const Rect content = cardContentRect(layout, index);
|
||||
const Rect thumb = cardThumbnailRect(layout, index);
|
||||
return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom());
|
||||
}
|
||||
|
||||
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
|
||||
if (cardCount <= 0) return -1;
|
||||
if (!contains(layout.grid, x, y)) return -1;
|
||||
const int cols = std::max(1, layout.columns);
|
||||
const int col = (x - layout.grid.x) / kBrowserCardWidth;
|
||||
const int row = (y - layout.grid.y) / kBrowserCardHeight;
|
||||
if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone)
|
||||
const int index = row * cols + col;
|
||||
if (index < 0 || index >= cardCount) return -1;
|
||||
// Only a hit inside the card CONTENT counts — a click in the inter-card gutter misses.
|
||||
if (!contains(cardContentRect(layout, index), x, y)) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) {
|
||||
if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{};
|
||||
const Rect& strip = layout.tabStrip;
|
||||
const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount);
|
||||
const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount);
|
||||
return Rect::ltrb(left, strip.y, right, strip.bottom());
|
||||
}
|
||||
|
||||
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
|
||||
if (tabCount <= 0) return -1;
|
||||
if (!contains(layout.tabStrip, x, y)) return -1;
|
||||
for (int i = 0; i < tabCount; ++i) {
|
||||
if (contains(filterTabRect(layout, tabCount, i), x, y)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,92 @@
|
||||
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
|
||||
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
|
||||
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
|
||||
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
|
||||
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
|
||||
// name + root/key badge and routes clicks into these functions.
|
||||
//
|
||||
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
|
||||
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
|
||||
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
|
||||
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
|
||||
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
|
||||
// module only "where does card i draw" / "what did the user click".
|
||||
//
|
||||
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
|
||||
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
|
||||
// scroll out keeps this module the stable card/tab geometry S12 builds on.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
|
||||
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
|
||||
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
|
||||
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
|
||||
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
|
||||
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
|
||||
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
|
||||
|
||||
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
|
||||
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||
struct BrowserLayout {
|
||||
Rect tabStrip; // top: the bank-filter tabs
|
||||
Rect grid; // below the tabs: where the capture cards tile
|
||||
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
|
||||
};
|
||||
|
||||
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
|
||||
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
|
||||
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
|
||||
// browser narrower than one card still lays out a single column. A zero/negative size
|
||||
// yields empty rects + columns==1.
|
||||
BrowserLayout layoutBrowser(int w, int h);
|
||||
|
||||
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
|
||||
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
|
||||
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
|
||||
// clips at paint time). A negative index yields an empty rect. Pure.
|
||||
Rect cardCellRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
|
||||
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
|
||||
Rect cardContentRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
|
||||
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
|
||||
// name + badge go in the remaining strip below. Pure.
|
||||
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
|
||||
// The shell draws the display name + root/key badge here. Pure.
|
||||
Rect cardLabelRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
|
||||
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
|
||||
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
|
||||
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
|
||||
|
||||
// --- Bank-filter tabs --------------------------------------------------------
|
||||
//
|
||||
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
|
||||
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
|
||||
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
|
||||
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
|
||||
// that bank (the shell filters its SampleChoice list before laying out cards).
|
||||
|
||||
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
|
||||
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
|
||||
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
|
||||
// empty rect. Pure.
|
||||
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
|
||||
|
||||
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
|
||||
// tab strip. Pure.
|
||||
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,41 @@
|
||||
// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes.
|
||||
|
||||
#include "core/instrument/ui/curve_popup.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
int clampDim(int want, int lo, int hi, int windowDim) {
|
||||
const int clamped = (std::max)(lo, (std::min)(hi, want));
|
||||
return (std::min)(clamped, (std::max)(0, windowDim));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
CurvePopupLayout computeCurvePopup(int w, int h) {
|
||||
CurvePopupLayout out;
|
||||
const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w);
|
||||
const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h);
|
||||
const int left = (w - sheetW) / 2;
|
||||
const int top = (h - sheetH) / 2;
|
||||
out.sheet = Rect::ltrb(left, top, left + sheetW, top + sheetH);
|
||||
|
||||
const int titleBottom = out.sheet.y + kCurvePopupTitleH;
|
||||
const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2;
|
||||
out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop,
|
||||
out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize);
|
||||
out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y,
|
||||
out.close.x - kCurvePopupPad, titleBottom);
|
||||
|
||||
out.curveBox = Rect::ltrb(out.sheet.x + kCurvePopupPad, titleBottom + 2,
|
||||
out.sheet.right() - kCurvePopupPad,
|
||||
out.sheet.bottom() - kCurvePopupPad);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) {
|
||||
return !contains(layout.sheet, x, y);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,48 @@
|
||||
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup
|
||||
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested
|
||||
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
|
||||
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
|
||||
//
|
||||
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED
|
||||
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's
|
||||
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520),
|
||||
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP"
|
||||
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the
|
||||
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
|
||||
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
|
||||
// and the Zone-panel inline editor share coordinates by construction.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
|
||||
inline constexpr int kCurvePopupMinW = 360;
|
||||
inline constexpr int kCurvePopupMaxW = 520;
|
||||
inline constexpr int kCurvePopupMinH = 260;
|
||||
inline constexpr int kCurvePopupMaxH = 380;
|
||||
inline constexpr int kCurvePopupTitleH = 22;
|
||||
inline constexpr int kCurvePopupCloseSize = 18;
|
||||
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
|
||||
|
||||
struct CurvePopupLayout {
|
||||
Rect sheet; // the bg/panel sheet, centered in the window
|
||||
Rect title; // the caption text rect (left part of the title row)
|
||||
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
|
||||
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
|
||||
};
|
||||
|
||||
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
|
||||
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
|
||||
// window never yields an overhanging sheet — centered; title row + close button at the top;
|
||||
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
|
||||
CurvePopupLayout computeCurvePopup(int w, int h);
|
||||
|
||||
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
|
||||
// The shell additionally gates on "no drag in flight" (spec). Pure.
|
||||
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,162 @@
|
||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spike editor layout constants. These are the editor's fixed metrics; the real
|
||||
// editor (S4/S5) will parameterize as its content demands.
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open
|
||||
// semantics, re-exported through the header's using-declaration.
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp the surface to non-negative extents so a degenerate view can't produce
|
||||
// inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
// Title bar spans the top, clamped so it never exceeds the client height.
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
|
||||
|
||||
// Canvas is everything below the title bar.
|
||||
out.canvas = Rect::ltrb(0, titleH, cw, ch);
|
||||
|
||||
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
|
||||
// fit inside the canvas so it never overhangs on a small view.
|
||||
const int bx = out.canvas.x + kButtonMargin;
|
||||
const int by = out.canvas.y + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
|
||||
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
// Must be within the canvas horizontally and at/below its top.
|
||||
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
|
||||
if (y < layout.canvas.y) return -1;
|
||||
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
|
||||
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom().
|
||||
if (y >= layout.canvas.bottom()) return -1;
|
||||
const int index = (y - layout.canvas.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
// Guard the bottom edge: a click below the last row's bottom is outside.
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
// Split the canvas vertically: the left column is the bank-sample list, the right
|
||||
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
|
||||
// the split point never crosses the canvas edges.
|
||||
const int canvasW = std::max(0, canvas.width);
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.x, canvas.right() - splitW);
|
||||
|
||||
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
|
||||
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
|
||||
|
||||
// "Add Zone" button spans the top of the zone panel, clamped to its height.
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
|
||||
out.addZoneButton =
|
||||
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
|
||||
out.zonePanel.y + addH);
|
||||
|
||||
// Zone rows stack below the button.
|
||||
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
|
||||
out.zonePanel.right(), out.zonePanel.bottom());
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
|
||||
top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.x || x >= list.right()) return -1;
|
||||
if (y < list.y || y >= list.bottom()) return -1;
|
||||
const int index = (y - list.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
|
||||
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
|
||||
top + kZoneRowHeight);
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.x || x >= area.right()) return ZoneHit{};
|
||||
if (y < area.y || y >= area.bottom()) return ZoneHit{};
|
||||
const int index = (y - area.y) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom()) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, right-to-left:
|
||||
// delete, root+, root-, high+, high-, low+, low-
|
||||
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
|
||||
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,142 @@
|
||||
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
|
||||
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
//
|
||||
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
|
||||
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
|
||||
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
|
||||
// tab_strip split their layout math out of the panel shell.
|
||||
//
|
||||
// The spike's editor is deliberately trivial (a title band + one clickable button),
|
||||
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
|
||||
// (S4/S5) grows, its layout math accretes here, not in the shell.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/ui/rect.h"
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
|
||||
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
|
||||
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
|
||||
// half-open convention). Aliased here so every instrument-ui call site keeps its
|
||||
// established `Rect` / `contains` spelling.
|
||||
using Rect = ::reasampler::ui::Rect;
|
||||
using ::reasampler::ui::contains;
|
||||
|
||||
// The regions the spike editor draws, derived from the current view size. All are
|
||||
// clamped to the client area so a degenerate (too-small) view never yields a region
|
||||
// that spills outside the surface.
|
||||
struct EditorLayout {
|
||||
Rect titleBar; // top band: the plugin name + a live-state readout
|
||||
Rect button; // a single clickable button (proves hit-test routing)
|
||||
Rect canvas; // the remaining surface below the title bar
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
|
||||
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
|
||||
// within [0,w] x [0,h], and the button never overhangs the canvas.
|
||||
EditorLayout layoutEditor(int w, int h);
|
||||
|
||||
// The editor's hit-test targets. kNone means the point landed on inert surface.
|
||||
enum class HitTarget {
|
||||
kNone,
|
||||
kButton,
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against a layout. The button wins only when the point is
|
||||
// inside the button rect; everything else (including the title bar and empty canvas)
|
||||
// is kNone in the spike.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
|
||||
//
|
||||
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
|
||||
// below the title bar; clicking a row selects that sample. This is the pure geometry:
|
||||
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
|
||||
// shell draws the names and routes the click into the processor's reloadInstrument.
|
||||
|
||||
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
|
||||
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
|
||||
// shell clips at paint time); a negative index yields an empty rect. Pure.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
|
||||
// outside the list (above the first row, past the last, or on the title bar). Pure.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
|
||||
//
|
||||
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
|
||||
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
|
||||
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
|
||||
// each zone row carries small nudge/delete controls so the user can set the range and
|
||||
// root note without a text field (LICE has no native numeric entry). All rectangle math
|
||||
// is here so the shell only draws + routes — the mirror of the sample-list split above.
|
||||
|
||||
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
|
||||
|
||||
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
|
||||
// canvas so a degenerate view yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half)
|
||||
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
|
||||
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton: where zone rows stack
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
|
||||
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
|
||||
// sampleList. Negative index -> empty. Pure.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
|
||||
// index -> empty. Pure.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
|
||||
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
|
||||
// root-, root+, delete). kZoneNone means the click missed a control
|
||||
// (e.g. on the label) — the shell may still treat that as "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
kLowUp,
|
||||
kHighDown,
|
||||
kHighUp,
|
||||
kRootDown,
|
||||
kRootUp,
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
|
||||
// which field within it. A click on the "Add Zone" button is reported separately by
|
||||
// addZoneHitTest — this covers only the zone rows.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
|
||||
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
|
||||
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
|
||||
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
// True if (x, y) lands on the "Add Zone" button. Pure.
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,86 @@
|
||||
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/embed_strip.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
|
||||
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
|
||||
// which tiles adjacent zones without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
|
||||
return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EmbedLayout layoutEmbed(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EmbedLayout out;
|
||||
|
||||
// The level band takes a fixed height at the bottom, but never so much that the keymap
|
||||
// above it falls below its minimum (or that the band exceeds the area). On a very short
|
||||
// area the band yields to the keymap entirely.
|
||||
int bandH = std::min(kEmbedLevelBandHeight, ch);
|
||||
if (ch - bandH < kEmbedKeymapMinHeight) {
|
||||
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
|
||||
}
|
||||
const int keymapBottom = ch - bandH;
|
||||
|
||||
out.keymap = Rect::ltrb(0, 0, cw, keymapBottom);
|
||||
out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch);
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
const Rect& band = layout.keymap;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
|
||||
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
|
||||
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
|
||||
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
|
||||
}
|
||||
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y) {
|
||||
if (zoneCount <= 0 || zones == nullptr) return -1;
|
||||
if (!contains(layout.keymap, x, y)) return -1;
|
||||
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
|
||||
for (int i = 0; i < zoneCount; ++i) {
|
||||
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
|
||||
if (contains(r, x, y)) return i;
|
||||
}
|
||||
return -1; // on the band but on an uncovered key
|
||||
}
|
||||
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level) {
|
||||
const Rect& band = layout.levelBand;
|
||||
if (band.width <= 0 || band.height <= 0) return Rect{};
|
||||
double l = level;
|
||||
if (l < 0.0) l = 0.0;
|
||||
if (l > 1.0) l = 1.0;
|
||||
const int fillW = static_cast<int>(l * band.width);
|
||||
if (fillW <= 0) return Rect{};
|
||||
return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom());
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,76 @@
|
||||
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3,
|
||||
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
|
||||
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives
|
||||
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
|
||||
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
|
||||
//
|
||||
// The strip is a single compact band REAPER draws inline in the track/mixer control panel
|
||||
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows:
|
||||
// * the zone layout — each performance zone as a horizontal segment across the keyboard
|
||||
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and
|
||||
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
|
||||
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
|
||||
// click maps to the zone whose key range covers that point, or -1.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
|
||||
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
|
||||
// a second rectangle type.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
|
||||
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
|
||||
inline constexpr int kEmbedKeyCount = 128;
|
||||
|
||||
// Fixed metrics for the strip, exposed so the shell and tests agree.
|
||||
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px)
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller
|
||||
|
||||
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal
|
||||
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM —
|
||||
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are
|
||||
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
|
||||
// zone never yields an out-of-strip rect.
|
||||
struct EmbedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
};
|
||||
|
||||
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to
|
||||
// the area so a degenerate (tiny) size never yields a region spilling outside the surface.
|
||||
struct EmbedLayout {
|
||||
Rect keymap; // top: the zone-segment band (the compact keymap)
|
||||
Rect levelBand; // bottom: the thin level/activity indicator
|
||||
};
|
||||
|
||||
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout.
|
||||
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area
|
||||
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or
|
||||
// negative size yields empty rects (no inversion).
|
||||
EmbedLayout layoutEmbed(int w, int h);
|
||||
|
||||
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect
|
||||
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g.
|
||||
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
|
||||
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
|
||||
// never an inverted one. Pure.
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click
|
||||
// outside the keymap band or on a key not covered by any zone. When zones overlap on a
|
||||
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match
|
||||
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y);
|
||||
|
||||
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the
|
||||
// returned rect is the left sub-rectangle of levelBand whose width is level * band width
|
||||
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,180 @@
|
||||
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
|
||||
|
||||
#include "core/instrument/ui/envelope_edit.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
|
||||
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
|
||||
double secondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's
|
||||
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching
|
||||
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
|
||||
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
|
||||
// area is degenerate.
|
||||
double gateSecondsPerPixel(const Rect& area) {
|
||||
const double pps = gatePxPerSecond(area);
|
||||
return pps > 0.0 ? 1.0 / pps : 0.0;
|
||||
}
|
||||
|
||||
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
|
||||
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
|
||||
double levelPerPixel(const Rect& area) {
|
||||
const int h = std::max(0, area.height);
|
||||
if (h <= 1) return 0.0;
|
||||
return 1.0 / static_cast<double>(h - 1);
|
||||
}
|
||||
|
||||
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
|
||||
bool isDraggable(EnvNode n) {
|
||||
switch (n) {
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode
|
||||
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a
|
||||
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
|
||||
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
|
||||
bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
switch (n) {
|
||||
case EnvNode::AttackEnd:
|
||||
case EnvNode::HoldEnd:
|
||||
case EnvNode::DecayEnd:
|
||||
case EnvNode::ReleaseEnd:
|
||||
return m == EnvMode::Gate;
|
||||
case EnvNode::FadeInEnd:
|
||||
case EnvNode::FadeOutStart:
|
||||
case EnvNode::LengthEnd:
|
||||
return m == EnvMode::Trigger;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
return false; // never draggable in any mode (isDraggable filters these anyway)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance —
|
||||
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never
|
||||
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only
|
||||
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
|
||||
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
|
||||
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
|
||||
NodeHit best;
|
||||
int bestDist = kNodeGrabRadius + 1;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
|
||||
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
|
||||
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
|
||||
bestDist = dist;
|
||||
best = NodeHit{true, v.node};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels) {
|
||||
AmpEnvelope out = grabEnv;
|
||||
if (!isDraggable(node) || !nodeInMode(node, grabEnv.mode)) return out;
|
||||
|
||||
const double secPerPx = secondsPerPixel(area, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
|
||||
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
|
||||
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
|
||||
// well-defined past the degenerate guard above.
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
|
||||
|
||||
switch (node) {
|
||||
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
|
||||
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
|
||||
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
break;
|
||||
case EnvNode::HoldEnd:
|
||||
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
|
||||
// level, so subtract the level delta).
|
||||
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(area);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
|
||||
break;
|
||||
}
|
||||
case EnvNode::ReleaseEnd:
|
||||
out.releaseSeconds =
|
||||
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
|
||||
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
|
||||
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
|
||||
// two fade nodes never cross (each clamps against the other), and length in [0, max].
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
|
||||
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
|
||||
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
|
||||
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
|
||||
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
|
||||
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
|
||||
case EnvNode::FadeInEnd: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeInFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
|
||||
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::FadeOutStart: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
// FadeOutStart sits at (1 - fadeOut) of the played span; dragging it LEFT (negative dx)
|
||||
// lengthens the fade-out. So the fade-out fraction moves OPPOSITE the pixel delta.
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
|
||||
const double hi = std::min(bounds.maxFadeOutFraction,
|
||||
1.0 - std::max(0.0, grabEnv.fadeInFraction));
|
||||
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
|
||||
break;
|
||||
}
|
||||
case EnvNode::LengthEnd: {
|
||||
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
|
||||
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
|
||||
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
|
||||
break;
|
||||
}
|
||||
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
break; // unreachable (isDraggable filtered above), kept for switch exhaustiveness
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,108 @@
|
||||
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
|
||||
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
|
||||
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
|
||||
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
|
||||
// through here, and commits the resulting params to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
//
|
||||
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
|
||||
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
|
||||
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
|
||||
// drag and a slider edit are two views on one source of truth and can never diverge.
|
||||
//
|
||||
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
|
||||
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
|
||||
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
|
||||
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
|
||||
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
|
||||
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
|
||||
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
|
||||
// so the two surfaces share one clamp by construction.
|
||||
//
|
||||
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
|
||||
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
|
||||
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
|
||||
// drawing-only ReleaseStart vertex are NOT draggable.
|
||||
//
|
||||
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
|
||||
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
|
||||
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
|
||||
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
|
||||
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
|
||||
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
|
||||
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
|
||||
//
|
||||
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
|
||||
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
|
||||
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
|
||||
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
|
||||
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
|
||||
inline constexpr int kNodeGrabRadius = 6;
|
||||
|
||||
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
|
||||
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
|
||||
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
|
||||
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
|
||||
struct EnvClampBounds {
|
||||
double maxAttackSeconds = 4.0; // upper bound of the attack slider
|
||||
double maxHoldSeconds = 4.0;
|
||||
double maxDecaySeconds = 4.0;
|
||||
double maxReleaseSeconds = 4.0;
|
||||
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
|
||||
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
|
||||
double maxFadeInFraction = 1.0;
|
||||
double maxFadeOutFraction = 1.0;
|
||||
double maxLengthFraction = 1.0;
|
||||
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
|
||||
};
|
||||
|
||||
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
|
||||
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
|
||||
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
|
||||
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable),
|
||||
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex
|
||||
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev
|
||||
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes
|
||||
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
|
||||
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
|
||||
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
|
||||
// from the right edge. Pure.
|
||||
struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
|
||||
|
||||
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
|
||||
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
|
||||
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
|
||||
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
|
||||
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
|
||||
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
|
||||
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
|
||||
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
|
||||
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
|
||||
// dyPixels is IGNORED for every time-only node.
|
||||
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
|
||||
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
|
||||
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
|
||||
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
|
||||
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,175 @@
|
||||
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
|
||||
|
||||
#include "core/instrument/ui/envelope_overlay.h"
|
||||
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0 || totalSeconds <= 0.0) return area.x;
|
||||
if (t < 0.0) t = 0.0;
|
||||
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
|
||||
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
|
||||
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
|
||||
double px = (t / totalSeconds) * static_cast<double>(w);
|
||||
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
|
||||
return area.x + static_cast<int>(px + 0.5);
|
||||
}
|
||||
|
||||
int gateTimedWidth(const Rect& area) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0) return 0;
|
||||
const int sustainPx =
|
||||
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
|
||||
return std::max(1, w - sustainPx);
|
||||
}
|
||||
|
||||
double gatePxPerSecond(const Rect& area) {
|
||||
const int timedW = gateTimedWidth(area);
|
||||
if (timedW <= 0) return 0.0;
|
||||
// Usable width = timed region minus the four per-segment separation bases and the last
|
||||
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
|
||||
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
|
||||
const double usable =
|
||||
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
|
||||
return usable / (4.0 * kGateStageMaxSeconds);
|
||||
}
|
||||
|
||||
int levelToY(const Rect& area, double level) {
|
||||
const int h = std::max(0, area.height);
|
||||
if (h <= 0) return area.y;
|
||||
if (level < 0.0) level = 0.0;
|
||||
if (level > 1.0) level = 1.0;
|
||||
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
|
||||
// range spans (h-1) pixels so both endpoints land ON a drawable row.
|
||||
const int span = h - 1;
|
||||
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
|
||||
return area.y + static_cast<int>(dy);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = timeToX(area, totalSeconds, t);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
return v;
|
||||
}
|
||||
|
||||
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
|
||||
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in
|
||||
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
|
||||
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
const int w = std::max(1, area.width);
|
||||
if (px < 0.0) px = 0.0;
|
||||
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
|
||||
EnvVertex v;
|
||||
v.node = node;
|
||||
v.x = area.x + static_cast<int>(px + 0.5);
|
||||
v.y = levelToY(area, level);
|
||||
v.level = level;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
|
||||
const double a = std::max(0.0, env.attackSeconds);
|
||||
const double h = std::max(0.0, env.holdSeconds);
|
||||
const double d = std::max(0.0, env.decaySeconds);
|
||||
const double r = std::max(0.0, env.releaseSeconds);
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
|
||||
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
|
||||
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment
|
||||
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually
|
||||
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
|
||||
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
|
||||
const int W = std::max(1, area.width);
|
||||
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
|
||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||
const double pps = gatePxPerSecond(area);
|
||||
|
||||
double xAttack = sep + a * pps; // AttackEnd
|
||||
double xHold = xAttack + sep + h * pps; // HoldEnd
|
||||
double xDecay = xHold + sep + d * pps; // DecayEnd (sustain node)
|
||||
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
|
||||
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
|
||||
|
||||
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT
|
||||
// preserving the minimum gaps, so trailing nodes stay individually separated instead of
|
||||
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to
|
||||
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
|
||||
const double xMax = static_cast<double>(W - 1);
|
||||
if (xRelease > xMax) {
|
||||
xRelease = xMax;
|
||||
xPlateau = std::min(xPlateau, xRelease - sep);
|
||||
xDecay = std::min(xDecay, xPlateau - sustainPx);
|
||||
xHold = std::min(xHold, xDecay - sep);
|
||||
xAttack = std::min(xAttack, xHold - sep);
|
||||
xAttack = std::max(xAttack, sep);
|
||||
xHold = std::max(xHold, xAttack + sep);
|
||||
xDecay = std::max(xDecay, xHold + sep);
|
||||
xPlateau = std::max(xPlateau, xDecay + sustainPx);
|
||||
xRelease = std::max(xRelease, xPlateau + sep);
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(6);
|
||||
pts.push_back(gateVtx(EnvNode::Origin, area, 0.0, 0.0));
|
||||
pts.push_back(gateVtx(EnvNode::AttackEnd, area, xAttack, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::HoldEnd, area, xHold, 1.0));
|
||||
pts.push_back(gateVtx(EnvNode::DecayEnd, area, xDecay, sus)); // sustain node
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseStart, area, xPlateau, sus)); // plateau end
|
||||
pts.push_back(gateVtx(EnvNode::ReleaseEnd, area, xRelease, 0.0));
|
||||
return pts;
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
|
||||
const double len = clamp01(env.lengthFraction);
|
||||
double fadeIn = clamp01(env.fadeInFraction);
|
||||
double fadeOut = clamp01(env.fadeOutFraction);
|
||||
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
|
||||
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
|
||||
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
|
||||
|
||||
const double playSeconds = len * totalSeconds;
|
||||
const double tFadeInEnd = fadeIn * playSeconds;
|
||||
const double tFadeOutStart = playSeconds - fadeOut * playSeconds; // where fade-out begins
|
||||
|
||||
std::vector<EnvVertex> pts;
|
||||
pts.reserve(4);
|
||||
pts.push_back(vtx(EnvNode::Origin, area, totalSeconds, 0.0, 0.0));
|
||||
pts.push_back(vtx(EnvNode::FadeInEnd, area, totalSeconds, tFadeInEnd, 1.0));
|
||||
pts.push_back(vtx(EnvNode::FadeOutStart, area, totalSeconds, tFadeOutStart, 1.0)); // unity end
|
||||
pts.push_back(vtx(EnvNode::LengthEnd, area, totalSeconds, playSeconds, 0.0)); // playEnd
|
||||
return pts;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
|
||||
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
|
||||
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
|
||||
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
|
||||
: triggerPolyline(env, area, totalSeconds);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,229 @@
|
||||
// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
|
||||
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
|
||||
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
|
||||
// and draws the node handles (via envelope_edit's hit-test).
|
||||
//
|
||||
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
|
||||
// grammar):
|
||||
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
|
||||
// sustain plateau, release sustain->0. Since there is no held note-off to draw
|
||||
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
|
||||
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
|
||||
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
|
||||
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
|
||||
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
|
||||
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
|
||||
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
|
||||
// node is individually grabbable at ANY param values, including the tier-0
|
||||
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
|
||||
// the canvas and the release is a visible, draggable segment.
|
||||
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
|
||||
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
|
||||
// waveform's exact time base so the shape lines up with the PCM under it.
|
||||
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
|
||||
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
|
||||
//
|
||||
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
|
||||
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
|
||||
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
|
||||
// longer needed (and never fires) in the shell.
|
||||
//
|
||||
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
|
||||
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
|
||||
// remain the only non-draggable anchors).
|
||||
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
|
||||
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
|
||||
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
|
||||
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
|
||||
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
|
||||
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
|
||||
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
|
||||
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
|
||||
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
|
||||
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
|
||||
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
|
||||
// right edge and wins the tie, so the fade can be dragged open from zero).
|
||||
//
|
||||
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
|
||||
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
|
||||
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
|
||||
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
|
||||
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
|
||||
// is the total sample duration in seconds, which the shell resolves once from the live rate and
|
||||
// the frame count and passes in — this module never sees a sample rate.
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
|
||||
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
|
||||
enum class EnvMode { Gate, Trigger };
|
||||
|
||||
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
|
||||
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
|
||||
// forward map (draw) and inverse map (edit) name the same nodes.
|
||||
//
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
|
||||
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
|
||||
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
|
||||
// release is edited by dragging ReleaseEnd.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
|
||||
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
|
||||
enum class EnvNode {
|
||||
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
|
||||
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
|
||||
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
|
||||
// Y sets sustainLevel)
|
||||
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
|
||||
// a DRAWING vertex only, not a draggable handle (release is edited at
|
||||
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
|
||||
// DecayEnd — the schematic note-off — Y = sustain level)
|
||||
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
|
||||
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
|
||||
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
|
||||
// X sets fadeOutFraction
|
||||
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
|
||||
};
|
||||
|
||||
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
|
||||
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
|
||||
//
|
||||
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
|
||||
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
|
||||
//
|
||||
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
|
||||
// lengthFraction is the played span as a fraction of the
|
||||
// post-start sample length (matching TriggerParams).
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
|
||||
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
|
||||
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
|
||||
// AmpEnvelope stores them as FRACTIONS of the played span:
|
||||
// fadeInFraction = fadeInFrames / playLengthFrames
|
||||
// fadeOutFraction = fadeOutFrames / playLengthFrames
|
||||
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
|
||||
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
|
||||
// converter on BOTH directions:
|
||||
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
|
||||
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
|
||||
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
|
||||
//
|
||||
// Unused fields for the active mode are ignored.
|
||||
struct AmpEnvelope {
|
||||
EnvMode mode = EnvMode::Gate;
|
||||
|
||||
// Gate (AHDSR), seconds + a dimensionless sustain level.
|
||||
double attackSeconds = 0.003;
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
|
||||
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
|
||||
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
|
||||
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
|
||||
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
|
||||
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
|
||||
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
|
||||
};
|
||||
|
||||
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
|
||||
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
|
||||
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
|
||||
struct EnvVertex {
|
||||
EnvNode node = EnvNode::Origin;
|
||||
int x = 0; // pixel x inside the overlay rect
|
||||
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
|
||||
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
|
||||
|
||||
bool operator==(const EnvVertex& o) const {
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level;
|
||||
}
|
||||
};
|
||||
|
||||
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
|
||||
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
|
||||
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
|
||||
// constant shared by the forward map (here) and the inverse map (envelope_edit).
|
||||
inline constexpr double kGateSustainDisplayFraction = 0.15;
|
||||
|
||||
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
|
||||
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
|
||||
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
|
||||
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
|
||||
// its neighbour. Shared by the forward map and the drag inverse.
|
||||
inline constexpr int kGateNodeSepPx = 8;
|
||||
|
||||
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
|
||||
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
|
||||
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
|
||||
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
|
||||
// (param clamps are caller-supplied in envelope_edit); only layout does.
|
||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
||||
|
||||
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve,
|
||||
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
|
||||
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
|
||||
int gateTimedWidth(const Rect& area);
|
||||
|
||||
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
|
||||
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
|
||||
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
|
||||
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
|
||||
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
|
||||
// dragged handle tracks the cursor 1:1.
|
||||
double gatePxPerSecond(const Rect& area);
|
||||
|
||||
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
|
||||
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
|
||||
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads
|
||||
// left-to-right in draw order, Origin first.
|
||||
//
|
||||
// TIME BASE (FA2).
|
||||
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED
|
||||
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release
|
||||
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
|
||||
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
|
||||
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
|
||||
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
|
||||
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
|
||||
// separated instead of piling on the last column; only a canvas too narrow to hold the
|
||||
// minimum gaps at all sacrifices separation (in-bounds wins).
|
||||
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
|
||||
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
|
||||
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
|
||||
//
|
||||
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
|
||||
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
|
||||
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
|
||||
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
|
||||
// polyline.
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds ->
|
||||
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins
|
||||
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
|
||||
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
|
||||
// (envelope_edit) use, so the drawn handle and its grab region agree.
|
||||
int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
|
||||
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1
|
||||
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
|
||||
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
|
||||
// and the node hit-test share.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,149 @@
|
||||
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/keyboard_strip.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kStripKeyCount - 1) return kStripKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
|
||||
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
|
||||
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
return bandLeft + (keyEdge * bandWidth) / kStripKeyCount;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
StripLayout layoutStrip(int w, int h) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
StripLayout out;
|
||||
out.keys = Rect::ltrb(0, 0, cw, ch);
|
||||
return out;
|
||||
}
|
||||
|
||||
int keyLeftX(const StripLayout& layout, int note) {
|
||||
const Rect& band = layout.keys;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
|
||||
// get a key's right edge, and 128 maps to the band right.
|
||||
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
|
||||
return keyEdgeToX(band.x, bandWidth, edge);
|
||||
}
|
||||
|
||||
Rect keyRect(const StripLayout& layout, int note) {
|
||||
const int n = clampNote(note);
|
||||
const int leftX = keyLeftX(layout, n);
|
||||
const int rightX = keyLeftX(layout, n + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
|
||||
return keyRect(layout, rootNote);
|
||||
}
|
||||
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
const Rect& band = layout.keys;
|
||||
if (!contains(band, x, y)) return -1;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
if (bandWidth <= 0) return -1;
|
||||
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
|
||||
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
|
||||
const int offset = x - band.x;
|
||||
int note = (offset * kStripKeyCount) / bandWidth;
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
|
||||
const Rect bar = zoneBarRect(layout, lowNote, highNote);
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width;
|
||||
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
|
||||
// a click exactly on the midpoint resizes low (deterministic).
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.x + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
}
|
||||
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
|
||||
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
|
||||
return ZoneGrab::kBody;
|
||||
}
|
||||
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
|
||||
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
|
||||
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
|
||||
}
|
||||
return ZoneBarHit{}; // on the band but on no bar
|
||||
}
|
||||
|
||||
bool isNaturalKey(int note) {
|
||||
// Clamp to the valid MIDI range before indexing.
|
||||
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
|
||||
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
|
||||
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
|
||||
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
|
||||
static constexpr bool kNatural[12] = {
|
||||
true, // 0 C
|
||||
false, // 1 C#
|
||||
true, // 2 D
|
||||
false, // 3 D#
|
||||
true, // 4 E
|
||||
true, // 5 F
|
||||
false, // 6 F#
|
||||
true, // 7 G
|
||||
false, // 8 G#
|
||||
true, // 9 A
|
||||
false, // 10 A#
|
||||
true, // 11 B
|
||||
};
|
||||
return kNatural[n % 12];
|
||||
}
|
||||
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
|
||||
if (dxPixels == 0) return clampNote(startNote);
|
||||
const int bandWidth = std::max(0, layout.keys.width);
|
||||
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
|
||||
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
|
||||
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
|
||||
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
|
||||
// the nearest key (half-key drag flips at the key centre) is achieved by adding
|
||||
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
|
||||
// formula except keyWidth is now derived from the same linear map (exact rational)
|
||||
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
|
||||
const int half = bandWidth / 2;
|
||||
int shift;
|
||||
if (dxPixels > 0) {
|
||||
shift = (dxPixels * kStripKeyCount + half) / bandWidth;
|
||||
} else {
|
||||
shift = -(((-dxPixels) * kStripKeyCount + half) / bandWidth);
|
||||
}
|
||||
return clampNote(startNote + shift);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,131 @@
|
||||
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first
|
||||
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle +
|
||||
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
|
||||
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
|
||||
// these functions.
|
||||
//
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span
|
||||
// idiom embed_strip uses). It serves TWO faces of the S10 editor:
|
||||
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on
|
||||
// the strip, click-a-key (or drag the marker) sets the capture's root note; and
|
||||
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
|
||||
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
|
||||
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
|
||||
//
|
||||
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
|
||||
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
|
||||
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
|
||||
// shell re-drawing the in-flight note; one coherent edit lands on release.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom),
|
||||
// so this header depends on editor_geometry.h rather than redefining a rectangle type.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named
|
||||
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay
|
||||
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
|
||||
inline constexpr int kStripKeyCount = 128;
|
||||
|
||||
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started
|
||||
// within this many pixels of the bar's left/right edge resizes that edge; a drag started
|
||||
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
|
||||
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
|
||||
inline constexpr int kStripEdgeGrabWidth = 6;
|
||||
|
||||
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band
|
||||
// takes the whole area today (a future octave-label lane can carve a sub-band here without
|
||||
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||
struct StripLayout {
|
||||
Rect keys; // the key band: the 128-key span maps linearly across keys.width
|
||||
};
|
||||
|
||||
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
|
||||
// negative size yields empty rects (no inversion).
|
||||
StripLayout layoutStrip(int w, int h);
|
||||
|
||||
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key
|
||||
// span maps linearly across keys.width; key N occupies the half-open pixel range
|
||||
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
|
||||
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
|
||||
int keyLeftX(const StripLayout& layout, int note);
|
||||
|
||||
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note),
|
||||
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
|
||||
// note clamps to [0,127]. Pure.
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`,
|
||||
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point
|
||||
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
|
||||
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs
|
||||
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes
|
||||
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
|
||||
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
|
||||
// Pure.
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag
|
||||
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab
|
||||
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
|
||||
// deselect).
|
||||
enum class ZoneGrab {
|
||||
kNone, // the point is not on this zone's bar
|
||||
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low
|
||||
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high
|
||||
kBody, // on the bar but not an edge -> move the whole span
|
||||
};
|
||||
|
||||
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the
|
||||
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within
|
||||
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
|
||||
// midpoint of a narrow bar (deterministic). Pure.
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
|
||||
|
||||
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on,
|
||||
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in
|
||||
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The
|
||||
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
|
||||
// length. Pure — no host containers at the boundary (a raw pointer pair, like
|
||||
// embed_strip::zoneAtPoint).
|
||||
struct ZoneBarHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneGrab grab = ZoneGrab::kNone;
|
||||
};
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time
|
||||
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note
|
||||
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to
|
||||
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
|
||||
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
|
||||
// body-move (apply to both edges with the SAME delta so the span is preserved), and
|
||||
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
|
||||
// key centre. Returns startNote unchanged for dxPixels==0.
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
|
||||
|
||||
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal
|
||||
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127]
|
||||
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
|
||||
// bad input). The 12 semitone positions within an octave:
|
||||
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
|
||||
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
|
||||
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
|
||||
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
|
||||
bool isNaturalKey(int note);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,156 @@
|
||||
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The knob-row width of a group: cells side by side (no inter-cell gap — the 48px cell
|
||||
// already carries its own breathing room around the 28px knob), plus the optional row
|
||||
// toggle after a kDeckToggleGap.
|
||||
int knobRowWidth(const DeckGroupDesc& g) {
|
||||
int w = static_cast<int>(g.cellIds.size()) * kDeckCellW;
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (w > 0) w += kDeckToggleGap;
|
||||
w += 2 * g.rowToggle.segWidth;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
// The caption-row width: the caption reserve plus the optional caption toggle.
|
||||
int captionRowWidth(const DeckGroupDesc& g) {
|
||||
int w = g.captionWidth;
|
||||
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
|
||||
return w;
|
||||
}
|
||||
|
||||
// Place one group's inner geometry given its box.
|
||||
DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
DeckGroupLayout out;
|
||||
out.id = g.id;
|
||||
out.box = box;
|
||||
|
||||
const int captionTop = box.y + kDeckGroupPadY;
|
||||
const int innerLeft = box.x + kDeckGroupPadX;
|
||||
const int innerRight = box.right() - kDeckGroupPadX;
|
||||
|
||||
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
|
||||
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
|
||||
if (g.captionToggle.id >= 0) {
|
||||
const int segW = g.captionToggle.segWidth;
|
||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
|
||||
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
|
||||
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
|
||||
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
|
||||
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
|
||||
}
|
||||
|
||||
// Knob row: fixed cells left-to-right, then the optional row toggle.
|
||||
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
|
||||
int x = innerLeft;
|
||||
for (int id : g.cellIds) {
|
||||
DeckCellLayout c;
|
||||
c.id = id;
|
||||
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
|
||||
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
|
||||
const int knobTop = cellTop + 4;
|
||||
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
|
||||
const int labelTop = knobTop + kDeckKnobSize + 4;
|
||||
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
|
||||
out.cells.push_back(c);
|
||||
x += kDeckCellW;
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (!g.cellIds.empty()) x += kDeckToggleGap;
|
||||
const int segW = g.rowToggle.segWidth;
|
||||
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
|
||||
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH);
|
||||
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
|
||||
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int deckGroupWidth(const DeckGroupDesc& g) {
|
||||
return (std::max)(captionRowWidth(g), knobRowWidth(g)) + 2 * kDeckGroupPadX;
|
||||
}
|
||||
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
||||
if (groups.empty()) return 0;
|
||||
int rows = 1;
|
||||
int x = 0;
|
||||
for (const DeckGroupDesc& g : groups) {
|
||||
const int w = deckGroupWidth(g);
|
||||
if (x > 0 && x + kDeckGroupGap + w > availWidth) {
|
||||
++rows;
|
||||
x = w;
|
||||
} else {
|
||||
x += (x > 0 ? kDeckGroupGap : 0) + w;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth) {
|
||||
const int rows = deckRowCount(groups, availWidth);
|
||||
if (rows == 0) return 0;
|
||||
return rows * kDeckGroupH + (rows - 1) * kDeckRowGap;
|
||||
}
|
||||
|
||||
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
||||
int availWidth) {
|
||||
DeckLayout out;
|
||||
if (groups.empty()) return out;
|
||||
int x = left;
|
||||
int y = top;
|
||||
bool rowHasGroup = false;
|
||||
out.rowCount = 1;
|
||||
for (const DeckGroupDesc& g : groups) {
|
||||
const int w = deckGroupWidth(g);
|
||||
if (rowHasGroup && (x + kDeckGroupGap + w) > (left + availWidth)) {
|
||||
// Wrap: whole trailing group onto the next row (mirror of deckRowCount).
|
||||
++out.rowCount;
|
||||
x = left;
|
||||
y += kDeckGroupH + kDeckRowGap;
|
||||
rowHasGroup = false;
|
||||
}
|
||||
if (rowHasGroup) x += kDeckGroupGap;
|
||||
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
|
||||
out.groups.push_back(layoutGroup(g, box));
|
||||
x = box.right();
|
||||
rowHasGroup = true;
|
||||
}
|
||||
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
|
||||
return out;
|
||||
}
|
||||
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
|
||||
for (const DeckGroupLayout& g : layout.groups) {
|
||||
if (!contains(g.box, x, y)) continue;
|
||||
if (g.captionToggle.id >= 0) {
|
||||
if (contains(g.captionToggle.seg0, x, y))
|
||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0};
|
||||
if (contains(g.captionToggle.seg1, x, y))
|
||||
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
|
||||
}
|
||||
if (g.rowToggle.id >= 0) {
|
||||
if (contains(g.rowToggle.seg0, x, y))
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 0};
|
||||
if (contains(g.rowToggle.seg1, x, y))
|
||||
return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
|
||||
}
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id >= 0 && contains(c.cell, x, y)) return {DeckHitKind::Knob, c.id, -1};
|
||||
}
|
||||
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap)
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,133 @@
|
||||
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition
|
||||
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like
|
||||
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids.
|
||||
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each
|
||||
// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags
|
||||
// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is
|
||||
// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two.
|
||||
//
|
||||
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each
|
||||
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's
|
||||
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width
|
||||
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band
|
||||
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row
|
||||
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar,
|
||||
// vertically centered). Groups that must keep stable geometry across a mode flip reserve
|
||||
// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never
|
||||
// reflows its neighbours.
|
||||
//
|
||||
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that
|
||||
// does not fit the remaining width starts a new deck row (whole groups only, never split).
|
||||
// The first group of a row always places even if wider than the row (degenerate width).
|
||||
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
|
||||
// give the ELASTIC HERO the rest (r11 band order).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
|
||||
inline constexpr int kDeckCellW = 48; // one knob cell
|
||||
inline constexpr int kDeckCellH = 58;
|
||||
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
|
||||
inline constexpr int kDeckCellLabelH = 12; // the Micro label band under the knob
|
||||
inline constexpr int kDeckCaptionH = 20; // the group caption row
|
||||
inline constexpr int kDeckToggleH = 18; // compact toggle segment height
|
||||
inline constexpr int kDeckGroupPadX = 6; // group box horizontal inner padding
|
||||
inline constexpr int kDeckGroupPadY = 4; // group box vertical inner padding
|
||||
inline constexpr int kDeckCaptionGap = 2; // caption row -> knob row gap
|
||||
inline constexpr int kDeckToggleGap = 4; // caption text -> toggle / cells -> row toggle gap
|
||||
inline constexpr int kDeckGroupGap = 12; // gap between groups on a row
|
||||
inline constexpr int kDeckRowGap = 8; // gap between wrapped deck rows
|
||||
// One group box: padding + caption + gap + cell row + padding.
|
||||
inline constexpr int kDeckGroupH =
|
||||
kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap + kDeckCellH + kDeckGroupPadY;
|
||||
|
||||
// A two-segment compact toggle (always 2 segments — the Mono/Stereo grammar). id -1 = absent.
|
||||
struct DeckToggleDesc {
|
||||
int id = -1; // shell control id returned by the hit-test; -1 = no toggle
|
||||
int segWidth = 44; // px per segment
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face).
|
||||
// `captionWidth` is the px the shell reserves for the caption text (this module does not
|
||||
// measure text — the house constant-metrics pattern).
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
DeckToggleDesc captionToggle; // right-anchored in the caption row; id -1 = none
|
||||
std::vector<int> cellIds; // knob cells; -1 = blank reserve
|
||||
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
|
||||
};
|
||||
|
||||
// --- Laid-out geometry ---------------------------------------------------------------
|
||||
|
||||
struct DeckToggleLayout {
|
||||
int id = -1;
|
||||
Rect seg0; // left segment
|
||||
Rect seg1; // right segment
|
||||
};
|
||||
|
||||
struct DeckCellLayout {
|
||||
int id = -1;
|
||||
Rect cell; // the full 48x58 cell
|
||||
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
|
||||
Rect label; // the 12px label band beneath the knob
|
||||
};
|
||||
|
||||
struct DeckGroupLayout {
|
||||
int id = 0;
|
||||
Rect box; // the fenced group box
|
||||
Rect caption; // caption text rect (left part of the caption row)
|
||||
DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
|
||||
std::vector<DeckCellLayout> cells;
|
||||
DeckToggleLayout rowToggle; // id -1 when absent
|
||||
};
|
||||
|
||||
struct DeckLayout {
|
||||
std::vector<DeckGroupLayout> groups;
|
||||
int rowCount = 0;
|
||||
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
|
||||
};
|
||||
|
||||
// The width of one group box: the wider of its caption row (caption + gap + toggle) and its
|
||||
// knob row (cells + gap + row toggle), plus the horizontal padding. Pure.
|
||||
int deckGroupWidth(const DeckGroupDesc& g);
|
||||
|
||||
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group
|
||||
// wrap (a group that does not fit the remaining row width starts a new row; the first group
|
||||
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
|
||||
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an
|
||||
// empty list. The shell bottom-anchors a band of exactly this height. Pure.
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
|
||||
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule.
|
||||
// Every rect is absolute. Pure — same inputs, same layout.
|
||||
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
||||
int availWidth);
|
||||
|
||||
// --- Hit-test --------------------------------------------------------------------------
|
||||
|
||||
enum class DeckHitKind { None, Knob, CaptionToggle, RowToggle };
|
||||
|
||||
struct DeckHit {
|
||||
DeckHitKind kind = DeckHitKind::None;
|
||||
int id = -1; // the control id of the hit element (cell id / toggle id)
|
||||
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
|
||||
};
|
||||
|
||||
// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the
|
||||
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption-
|
||||
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
|
||||
// Pure — the shell's routing entry point.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,160 @@
|
||||
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
|
||||
// editor parameter panel. No host types; only the shared Rect + contains().
|
||||
|
||||
#include "core/instrument/ui/param_slider.h"
|
||||
|
||||
#include "core/util/clamp01.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls) {
|
||||
std::vector<ControlRow> out;
|
||||
if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out;
|
||||
out.reserve(controls.size());
|
||||
|
||||
// The label column is clamped so a narrow panel still leaves a control column.
|
||||
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2));
|
||||
int rowTop = panel.y;
|
||||
for (const ControlDesc& d : controls) {
|
||||
ControlRow r;
|
||||
r.id = d.id;
|
||||
r.kind = d.kind;
|
||||
const int rowBottom = rowTop + kControlRowHeight;
|
||||
r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom);
|
||||
r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom);
|
||||
r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom);
|
||||
out.push_back(r);
|
||||
rowTop = rowBottom + kControlRowGap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect toggleSegmentRect(const Rect& control, int seg) {
|
||||
if (seg < 0 || seg >= kToggleSegments) return Rect{};
|
||||
const int w = control.width;
|
||||
if (w <= 0 || control.height <= 0) return Rect{};
|
||||
const int segW = w / kToggleSegments;
|
||||
const int left = control.x + seg * segW;
|
||||
// The last segment absorbs the width remainder so the segments tile the whole control.
|
||||
const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW;
|
||||
return Rect::ltrb(left, control.y, right, control.bottom());
|
||||
}
|
||||
|
||||
int toggleSegmentHitTest(const Rect& control, int x, int y) {
|
||||
if (!contains(control, x, y)) return -1;
|
||||
for (int seg = 0; seg < kToggleSegments; ++seg) {
|
||||
if (contains(toggleSegmentRect(control, seg), x, y)) return seg;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Rect sliderTrackRect(const Rect& control) {
|
||||
// Inset a half-handle at each end so the handle stays fully inside the control at value
|
||||
// 0 and 1. The handle CENTER ranges across [track.x, track.right()].
|
||||
const int half = kSliderHandleWidth / 2;
|
||||
if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{};
|
||||
return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom());
|
||||
}
|
||||
|
||||
Rect sliderHandleRect(const Rect& control, double value) {
|
||||
const Rect track = sliderTrackRect(control);
|
||||
if (track.width <= 0) return Rect{};
|
||||
if (value < 0.0) value = 0.0;
|
||||
if (value > 1.0) value = 1.0;
|
||||
const int span = track.width; // handle-center movable span
|
||||
const int centerX = track.x + static_cast<int>(value * span + 0.5);
|
||||
const int half = kSliderHandleWidth / 2;
|
||||
return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth,
|
||||
control.bottom());
|
||||
}
|
||||
|
||||
double valueAtPoint(const Rect& control, int x) {
|
||||
const Rect track = sliderTrackRect(control);
|
||||
const int span = track.width;
|
||||
if (span <= 0) return 0.0;
|
||||
if (x <= track.x) return 0.0;
|
||||
if (x >= track.right()) return 1.0;
|
||||
return static_cast<double>(x - track.x) / static_cast<double>(span);
|
||||
}
|
||||
|
||||
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
// Normalize an angle in degrees to [0, 360).
|
||||
double normDeg(double deg) {
|
||||
deg = std::fmod(deg, 360.0);
|
||||
if (deg < 0.0) deg += 360.0;
|
||||
// Guard: fmod can return exactly 360.0 on some implementations due to floating-point
|
||||
// rounding; fold it back to 0.
|
||||
if (deg >= 360.0) deg -= 360.0;
|
||||
return deg;
|
||||
}
|
||||
|
||||
|
||||
} // namespace
|
||||
|
||||
KnobGeometry computeKnob(const Rect& cell) {
|
||||
if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{};
|
||||
KnobGeometry g;
|
||||
g.centerX = (cell.x + cell.right()) / 2.0;
|
||||
g.centerY = (cell.y + cell.bottom()) / 2.0;
|
||||
g.radius = (std::min)(cell.width, cell.height) / 2.0;
|
||||
return g;
|
||||
}
|
||||
|
||||
bool knobHitTest(const KnobGeometry& knob, int x, int y) {
|
||||
if (knob.radius <= 0.0) return false;
|
||||
const double dx = x - knob.centerX;
|
||||
const double dy = y - knob.centerY;
|
||||
// Boundary exclusive: matches the module's half-open Rect convention.
|
||||
return dx * dx + dy * dy < knob.radius * knob.radius;
|
||||
}
|
||||
|
||||
double knobSweepDeg(const KnobArc& arc) {
|
||||
const double sweep = normDeg(arc.endDeg) - normDeg(arc.startDeg);
|
||||
// An end at-or-behind the start wraps clockwise past 12 o'clock; equal angles mean a
|
||||
// full circle.
|
||||
return sweep <= 0.0 ? sweep + 360.0 : sweep;
|
||||
}
|
||||
|
||||
double knobValueAngleDeg(const KnobArc& arc, double value) {
|
||||
return normDeg(normDeg(arc.startDeg) + knobSweepDeg(arc) * clamp01(value));
|
||||
}
|
||||
|
||||
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value) {
|
||||
// Clock angle -> screen direction: 0° points up (-y), 90° points right (+x).
|
||||
const double rad = knobValueAngleDeg(arc, value) * kPi / 180.0;
|
||||
return KnobPoint{knob.centerX + knob.radius * std::sin(rad),
|
||||
knob.centerY - knob.radius * std::cos(rad)};
|
||||
}
|
||||
|
||||
double knobDragValue(double startValue, int dyPixels, int dragRangePixels) {
|
||||
const double start = clamp01(startValue);
|
||||
if (dragRangePixels <= 0) return start;
|
||||
// Screen y grows downward: an upward drag (negative dy) increases the value.
|
||||
return clamp01(start - static_cast<double>(dyPixels) / dragRangePixels);
|
||||
}
|
||||
|
||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
|
||||
for (const ControlRow& r : rows) {
|
||||
if (r.kind == ControlKind::Toggle) {
|
||||
if (contains(r.control, x, y)) return r.id;
|
||||
} else if (r.kind == ControlKind::Knob) {
|
||||
if (knobHitTest(computeKnob(r.control), x, y)) return r.id;
|
||||
} else { // Slider — the interactive area is the track
|
||||
if (contains(sliderTrackRect(r.control), x, y)) return r.id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,180 @@
|
||||
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
|
||||
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
|
||||
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
|
||||
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
|
||||
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// draws each row (label + track/segments + handle) and routes clicks/drags into these
|
||||
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
|
||||
//
|
||||
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
|
||||
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
|
||||
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
|
||||
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
|
||||
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
|
||||
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
|
||||
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
|
||||
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
|
||||
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
|
||||
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
|
||||
// engine-free so it tests without the audio core).
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed control-panel metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
|
||||
inline constexpr int kControlRowGap = 4; // vertical gap below each row
|
||||
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
|
||||
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
|
||||
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
|
||||
|
||||
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
|
||||
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
|
||||
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
|
||||
// (up = increase).
|
||||
enum class ControlKind { Toggle, Slider, Knob };
|
||||
|
||||
// One control the shell places in the panel, in stack order. `id` is the shell's own control
|
||||
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
|
||||
// shell routes the interaction to the right engine param — this module never interprets it.
|
||||
struct ControlDesc {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
};
|
||||
|
||||
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
|
||||
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
|
||||
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
|
||||
struct ControlRow {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
Rect row; // the full row (label column + control column)
|
||||
Rect label; // the label column at the left
|
||||
Rect control; // the control column to the right of the label (track / toggle area)
|
||||
};
|
||||
|
||||
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
|
||||
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
|
||||
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
|
||||
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
|
||||
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
|
||||
// control list or a degenerate panel yields an empty vector. Pure.
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls);
|
||||
|
||||
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
|
||||
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
|
||||
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
|
||||
// control rect yields an empty rect. Pure.
|
||||
Rect toggleSegmentRect(const Rect& control, int seg);
|
||||
|
||||
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
|
||||
// miss (outside the control area). Pure.
|
||||
int toggleSegmentHitTest(const Rect& control, int x, int y);
|
||||
|
||||
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
|
||||
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
|
||||
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value
|
||||
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
|
||||
// empty rect. Pure.
|
||||
Rect sliderTrackRect(const Rect& control);
|
||||
|
||||
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
|
||||
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
|
||||
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
|
||||
Rect sliderHandleRect(const Rect& control, double value);
|
||||
|
||||
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
|
||||
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
|
||||
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
|
||||
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
|
||||
double valueAtPoint(const Rect& control, int x);
|
||||
|
||||
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
|
||||
//
|
||||
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
|
||||
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
|
||||
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
|
||||
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
|
||||
// angles mean a full 360° sweep.
|
||||
//
|
||||
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping
|
||||
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the
|
||||
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles
|
||||
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout
|
||||
// spec lands.
|
||||
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
|
||||
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
|
||||
|
||||
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
|
||||
inline constexpr int kKnobDragRangePixels = 128;
|
||||
|
||||
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
|
||||
struct KnobArc {
|
||||
double startDeg = kKnobArcStartDeg;
|
||||
double endDeg = kKnobArcEndDeg;
|
||||
};
|
||||
|
||||
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
|
||||
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
|
||||
struct KnobGeometry {
|
||||
double centerX = 0.0;
|
||||
double centerY = 0.0;
|
||||
double radius = 0.0;
|
||||
};
|
||||
|
||||
// A pixel-space point (the needle endpoint the shell draws to).
|
||||
struct KnobPoint {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
|
||||
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
|
||||
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full
|
||||
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses
|
||||
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
|
||||
// wants to draw a smaller circle it must center it within `row.control` and accept that the
|
||||
// hit area is the larger column-inscribed circle. Pure.
|
||||
KnobGeometry computeKnob(const Rect& cell);
|
||||
|
||||
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the
|
||||
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
|
||||
bool knobHitTest(const KnobGeometry& knob, int x, int y);
|
||||
|
||||
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
|
||||
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
|
||||
double knobSweepDeg(const KnobArc& arc);
|
||||
|
||||
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
|
||||
// linear between, returned normalized to [0, 360). Pure.
|
||||
double knobValueAngleDeg(const KnobArc& arc, double value);
|
||||
|
||||
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
|
||||
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
|
||||
// (or lerps toward the center for a shorter needle). Pure.
|
||||
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
|
||||
|
||||
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
|
||||
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
|
||||
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
|
||||
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
|
||||
// Pure — the inverse map for the knob's drag interaction.
|
||||
double knobDragValue(double startValue, int dyPixels,
|
||||
int dragRangePixels = kKnobDragRangePixels);
|
||||
|
||||
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
|
||||
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
|
||||
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
|
||||
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
|
||||
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
|
||||
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
|
||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,99 @@
|
||||
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/waveform_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs (int overload)
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
||||
if (f < 0) return 0;
|
||||
if (f > frameCount) return frameCount;
|
||||
return f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (frameCount <= 0 || w <= 0) return area.x;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
|
||||
// visually centered on its frame; the divide is exact rational (multiply first).
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.x + static_cast<int>(num / frameCount);
|
||||
}
|
||||
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (frameCount <= 0 || w <= 0) return 0;
|
||||
if (x <= area.x) return 0;
|
||||
if (x >= area.right()) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
|
||||
// agree at bin granularity.
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || frames == nullptr) return -1;
|
||||
if (!contains(area, x, y)) return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int mx = frameToX(area, frameCount, frames[i]);
|
||||
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels) {
|
||||
const std::int64_t start = clampFrame(startFrame, frameCount);
|
||||
if (dxPixels == 0) return start;
|
||||
const int w = std::max(0, area.width);
|
||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
||||
const std::int64_t magnitude =
|
||||
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
|
||||
static_cast<std::int64_t>(w) / 2) /
|
||||
static_cast<std::int64_t>(w);
|
||||
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
|
||||
return clampFrame(start + shift, frameCount);
|
||||
}
|
||||
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target) {
|
||||
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
|
||||
// Clamp target into a valid sample index [0, frames).
|
||||
std::int64_t t = target;
|
||||
if (t < 0) t = 0;
|
||||
if (t > frames - 1) t = frames - 1;
|
||||
|
||||
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
|
||||
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
|
||||
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
|
||||
auto isCrossing = [&](std::int64_t i) -> bool {
|
||||
if (i < 1 || i >= frames) return false;
|
||||
const AudioSample a = pcm[i - 1];
|
||||
const AudioSample b = pcm[i];
|
||||
if (b == 0.0f) return true; // a sample on zero is its own crossing
|
||||
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
|
||||
};
|
||||
|
||||
if (isCrossing(t)) return t;
|
||||
for (std::int64_t d = 1; d < frames; ++d) {
|
||||
const std::int64_t lo = t - d;
|
||||
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
|
||||
const std::int64_t hi = t + d;
|
||||
if (hi < frames && isCrossing(hi)) return hi;
|
||||
// Stop once both probes have run off both ends — no crossing anywhere.
|
||||
if (lo < 1 && hi >= frames) break;
|
||||
}
|
||||
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,85 @@
|
||||
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
|
||||
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
|
||||
//
|
||||
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
|
||||
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
|
||||
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
|
||||
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
|
||||
// different marker set (start + %-length end + fades) without reworking the machinery.
|
||||
//
|
||||
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
|
||||
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
|
||||
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
|
||||
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
|
||||
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
|
||||
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
|
||||
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The width (px) of a marker's grab region either side of its x line: a grab within this many
|
||||
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
|
||||
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
|
||||
// distinguishable.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x,
|
||||
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an
|
||||
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
|
||||
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
|
||||
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount.
|
||||
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
|
||||
// to the same frame at bin granularity).
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
|
||||
|
||||
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
|
||||
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
|
||||
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
|
||||
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
|
||||
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
|
||||
// keyboard_strip::zoneBarAtPoint.
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
|
||||
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
|
||||
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
|
||||
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
|
||||
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
|
||||
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
|
||||
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
|
||||
// for dxPixels == 0.
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels);
|
||||
|
||||
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
|
||||
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
|
||||
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
|
||||
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
|
||||
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
|
||||
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
|
||||
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
|
||||
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
|
||||
// decoded PCM the shell already holds; no host types, no file I/O.
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
Reference in New Issue
Block a user