instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+10 -8
View File
@@ -243,10 +243,12 @@ anything for a trigger shape.
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
extension's `persist` ext-state module (that would make it project-global rather than
per-instance and leak an instrument concern into the extension's key space).
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
already-saved instance with no stored curve now plays every velocity at unity under the
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
- **Velocity curves** — three of them (amp, pitch, filter), all per-instance, edited from ONE
deck group. The amp curve is the one non-back-compat surface in S-VIEW: an already-saved
instance with no stored curve now plays every velocity at unity under the flat-default
(Option A), not bit-identical to the old linear `velocity/127` mapping — a deliberate,
Daniel-approved behavior change. The pitch and filter curves are bipolar and off by default
(see `velocity_curve` in Modules).
## Modules
@@ -260,13 +262,13 @@ anything for a trigger shape.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
- `velocity_curve` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation depth for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve is BOTH the shape and the amount — there is no depth control behind it, which is why the filter's `velAmount` retired into it. eval is HOMOGENEOUS in y (scaling every knot by k scales the curve by k exactly); the codec's pre-v12 lift rests on that.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v11), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v12), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve. v12 also RE-INTERPRETS two frozen slots inside the v9 filter tail (its velocity curve is now bipolar and self-scaling; the retired velAmount slot carries a constant 1.0) — same bytes, version-keyed meaning, with the pre-v12 fold documented in `component_state_io.h`.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -276,7 +278,7 @@ anything for a trigger shape.
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. Also `previewGlyph`, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap, plus `markerHandleRect` — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
@@ -285,7 +287,7 @@ anything for a trigger shape.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then velocity/voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer. Also home to `CurveTarget` + `curveTargetFor` — the VELOCITY group's three cells are popup openers, not dials, and that predicate is the ONE place they are named, so paint, hit-test routing and the popup's title all agree. MASTER is reserved for post-voice-mixer concerns, which is why the curves sit in their own group immediately left of VOICE rather than there.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure staged-envelope→polyline geometry for the Sample-view overlay (read from `envelope_overlay.h`): maps a `StageEnvelope` to a polyline inside a rect under whichever of TWO layout policies its `EnvKind` selects — an AHDSR draws a bounded param-domain schematic with its release RIGHT-ANCHORED to the canvas edge, an AHD draws 1:1 over the waveform's own time axis — plus a round mid-segment knot on every sloped stage that has a duration. Every vertex clamped in-canvas. Shares the `EnvNode`/`StageEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes and their curve knots (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break, knots appended last so a coincident endpoint handle wins); `resolveNodeDrag` maps a pixel delta since grab to a new `StageEnvelope` under the same caller-supplied per-param clamp bounds the knobs use — a drag can never produce a param a knob couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag, knot-drag and knob-edit read/write one shared model and can never diverge.
+17 -12
View File
@@ -110,31 +110,32 @@ struct PitchEnvParams {
// normalized control positions verbatim rather than a parallel set, so no control range is
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
//
// The three modulation depths below land in that same normalized cutoff domain and sum
// before a single clamp; all three are zero/neutral by default.
// The modulation depths below land in that same normalized cutoff domain and sum before a
// single clamp; all are zero/neutral by default.
struct FilterParams {
bool enabled = false;
instrument::engine::filter::FilterSettings settings;
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
// The filter envelope takes the same shape the amp does under the active play mode:
// AHDSR in Gate, AHD in Trigger. Both are stored, so a mode flip cannot lose either
// mode's dialled values (see core/instrument/CLAUDE.md).
AdsrParams env; // Gate: the same staged AHDSR the amp runs; frames
AhdParams trigEnv; // Trigger: the same staged AHD the amp runs; frames
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
// because a flat curve under a depth control would make every velocity the same offset;
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
// flat() regardless, since that repair has no curve-specific fallback.
VelocityCurve velocityCurve = VelocityCurve::linear();
// Velocity -> cutoff, in the normalized cutoff domain. BIPOLAR, so the curve is both the
// shape and the amount — there is no separate depth knob behind it (the retired velAmount
// was exactly that, and a signed depth multiplying a signed curve made the sign unreadable).
VelocityCurve velocityCurve = VelocityCurve::zero();
};
// Full-scale of the velocity->pitch curve: y = +/-1 transposes by this many semitones. Shared
// with the pitch envelope's own depth throw so the two pitch modulators speak one range.
inline constexpr double kVelocityPitchRangeSemitones = 24.0;
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
// Varispeed, pitch envelope off, filter off, no velocity->pitch) — core regression tests rely
// on this; the Preserve product default is layered on at (de)serialization, see
// kDefaultPitchEngine.
struct PlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate amp
@@ -142,6 +143,10 @@ struct PlayParams {
AhdParams trigAhd; // Trigger amp
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv;
// Velocity -> pitch offset, scaled by kVelocityPitchRangeSemitones. Bipolar and flat at 0
// by default, so it transposes nothing until a curve is drawn. Folded into the voice's
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
FilterParams filter;
};
+64 -47
View File
@@ -11,19 +11,19 @@ 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); }
double clampValue(double a, CurveDomain d) { return std::clamp(a, curveYMin(d), kCurveYMax); }
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
// (pixel y increases downward, so this axis is inverted relative to amp).
// X spans the width for [0,127]; Y spans (height-1) rows for the domain's range with its max at
// the TOP (pixel y increases downward, so this axis is inverted relative to the value).
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) {
double valuePerPixel(const VelocityCurve::Box& box, CurveDomain d) {
const int h = std::max(0, box.height);
if (h <= 1) return 0.0;
return (kAmpMax - kAmpMin) / static_cast<double>(h - 1);
return (kCurveYMax - curveYMin(d)) / static_cast<double>(h - 1);
}
int velToX(const VelocityCurve::Box& box, double velocity) {
const int w = std::max(0, box.width);
@@ -31,10 +31,11 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
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) {
int valueToY(const VelocityCurve::Box& box, double value, CurveDomain d) {
const int h = std::max(0, box.height);
if (h <= 1) return box.top;
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
const double lo = curveYMin(d);
const double frac = (clampValue(value, d) - lo) / (kCurveYMax - lo);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
}
@@ -42,39 +43,49 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
c.points_ = {{kVelMin, kCurveYMax}, {kVelMax, kCurveYMax}};
return c;
}
VelocityCurve VelocityCurve::linear() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
c.points_ = {{kVelMin, 0.0}, {kVelMax, kCurveYMax}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
VelocityCurve VelocityCurve::zero() {
VelocityCurve c;
c.domain_ = CurveDomain::Bipolar;
c.points_ = {{kVelMin, 0.0}, {kVelMax, 0.0}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots).
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp);
p.value = clampValue(p.value, domain);
}
std::stable_sort(pts.begin(), pts.end(),
[](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity;
});
if (pts.size() < 2) return flat();
if (pts.size() < 2) {
return domain == CurveDomain::Bipolar ? zero() : flat();
}
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value});
} else {
pts.front().velocity = kVelMin;
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
pts.push_back(VelocityPoint{kVelMax, pts.back().value});
} else {
pts.back().velocity = kVelMax;
}
VelocityCurve c;
c.domain_ = domain;
c.points_ = std::move(pts);
return c;
}
@@ -84,7 +95,8 @@ namespace {
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
// makes the spline reproduce a straight line for linear()-style input. Homogeneous of degree 1
// in the secants, which is what makes eval homogeneous in y (see the header).
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
@@ -95,29 +107,29 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
} // namespace
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax;
if (points_.size() == 1) return clampAmp(points_[0].amp);
if (points_.empty()) return curveNeutral(domain_);
if (points_.size() == 1) return clampValue(points_[0].value, domain_);
const double v = clampVelocity(velocity);
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
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): zero-width segment, no interior to blend.
if (span <= 0.0) return clampAmp(b.amp);
if (span <= 0.0) return clampValue(b.value, domain_);
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.value, b.value]
// between the two knots (no overshoot), reproducing a straight line for collinear input.
const double d = (b.amp - a.amp) / span;
const double d = (b.value - a.value) / span;
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;
const double dPrev = (a.value - prev.value) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0;
@@ -128,7 +140,7 @@ double VelocityCurve::eval(double velocity) const {
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;
const double dNext = (next.value - b.value) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0;
@@ -142,15 +154,15 @@ double VelocityCurve::eval(double velocity) const {
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);
const double y = h00 * a.value + h10 * span * mA + h01 * b.value + h11 * span * mB;
return clampValue(y, domain_);
}
}
return clampAmp(points_.back().amp); // unreachable (v is between the endpoints)
return clampValue(points_.back().value, domain_); // unreachable (v is between the endpoints)
}
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
std::size_t VelocityCurve::addPoint(double velocity, double value) {
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_)};
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
@@ -158,12 +170,12 @@ std::size_t VelocityCurve::addPoint(double velocity, double amp) {
return i;
}
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double amp) {
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
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 newValue = clampValue(value, domain_);
double newVel;
if (isFirst) {
newVel = kVelMin;
@@ -174,7 +186,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi);
}
points_[index] = VelocityPoint{newVel, newAmp};
points_[index] = VelocityPoint{newVel, newValue};
return points_[index];
}
@@ -185,12 +197,13 @@ bool VelocityCurve::deletePoint(std::size_t index) {
return true;
}
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const VelocityPoint& p) {
return CurvePixel{velToX(box, p.velocity), ampToY(box, p.amp)};
VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box,
const VelocityPoint& p) const {
return CurvePixel{velToX(box, p.velocity), valueToY(box, p.value, domain_)};
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) const {
// Exact inverse of velToX/valueToY (within one pixel); degenerate dims collapse the same way.
VelocityPoint p;
const int w = std::max(0, box.width);
const int h = std::max(0, box.height);
@@ -198,17 +211,19 @@ VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
? 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));
const double lo = curveYMin(domain_);
p.value = (h <= 1)
? kCurveYMax
: clampValue(kCurveYMax - static_cast<double>(y - box.top) / static_cast<double>(h - 1) *
(kCurveYMax - lo),
domain_);
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);
const int py = valueToY(box, points_[i].value, domain_);
if (std::abs(x - px) <= kCurveNodeGrabRadius && std::abs(y - py) <= kCurveNodeGrabRadius) {
return static_cast<int>(i);
}
@@ -221,22 +236,24 @@ VelocityCurve VelocityCurve::resolvePointDrag(const VelocityCurve& grabCurve, st
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 double valPerPx = valuePerPixel(box, grabCurve.domain_);
if (velPerPx <= 0.0 || valPerPx <= 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
// Y increases downward but the value increases upward, so a downward drag (positive dy)
// LOWERS the value.
const double newValue = grab.value - static_cast<double>(dyPixels) * valPerPx;
out.movePoint(index, newVel, newValue); // applies box + neighbour-X + endpoint-pin clamps
return out;
}
bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
if (domain_ != other.domain_) return false;
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;
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
}
return true;
}
+49 -28
View File
@@ -1,7 +1,8 @@
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
// a transitive dependency on editor-layout types.
// velocity_curve.h — the velocity->modulation transfer curve, shared by all three
// destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once
// per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit
// pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must
// not gain a transitive dependency on editor-layout types.
#pragma once
@@ -10,17 +11,29 @@
namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
// The MIDI velocity domain [0,127] the X span 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;
inline constexpr double kCurveYMax = 1.0;
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing
// curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation depth — the pitch and filter
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
// bipolar curve is therefore both the shape and the amount: there is no separate depth
// control behind it.
enum class CurveDomain { Unipolar, Bipolar };
constexpr double curveYMin(CurveDomain d) { return d == CurveDomain::Bipolar ? -1.0 : 0.0; }
// The value that changes nothing in each domain — unity gain, or zero modulation. Every
// defensive fallback lands here so a corrupt blob loses the shaping rather than inventing one.
constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar ? 0.0 : 1.0; }
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — 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]
double value = 0.0; // Y, in the owning curve's domain
};
// Pick radius (px) around a node's drawn point for the editor hit-test.
@@ -28,44 +41,50 @@ inline constexpr int kCurveNodeGrabRadius = 6;
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never
// deletable. eval is HOMOGENEOUS in y — scaling every knot's value by k scales the whole curve
// by k exactly, which is what lets the codec fold a retired depth control into stored knots.
class VelocityCurve {
public:
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
// pre-existing linear() response.
static VelocityCurve flat();
static VelocityCurve linear();
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
static VelocityCurve zero();
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
// blob yields a well-formed curve, never an invariant-violating one.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
// each point into `domain`, stable-sorts by velocity, forces both endpoints present
// (synthesized if missing), falls back to the domain's neutral curve if fewer than 2 usable
// points remain. A corrupt/truncated blob yields a well-formed curve, never an
// invariant-violating one.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain);
CurveDomain domain() const { return domain_; }
const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); }
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
// one-point curve returns that point's amp.
// Degenerate cases (shouldn't occur post-construction): empty curve returns the domain's
// neutral; a one-point curve returns that point's value.
double eval(double velocity) const;
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
// subsequent move can separate them. Returns the inserted index.
std::size_t addPoint(double velocity, double amp);
std::size_t addPoint(double velocity, double value);
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
// endpoints are pinned in X (only their value moves); out-of-range index is a no-op.
VelocityPoint movePoint(std::size_t index, double velocity, double value);
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
// returning false.
bool deletePoint(std::size_t index);
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
// top). Passed explicitly rather than a Rect — see header preamble.
// The drawn box, in pixels: X = velocity across the width, Y = value UP the height (the
// domain's max at top). Passed explicitly rather than a Rect — see header preamble.
struct Box {
int left = 0;
int top = 0;
@@ -82,14 +101,15 @@ public:
int x = 0;
int y = 0;
};
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p) const;
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space
// click lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1
// reads the domain's max (the top row is what a collapsed box draws).
VelocityPoint pointFromPixel(const Box& box, int x, int y) const;
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
// Maps the pixel delta to velocity/value over the box, then applies movePoint's clamp. Zero
// width/height box or out-of-range index returns grabCurve unchanged.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels);
@@ -100,6 +120,7 @@ private:
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// invariant, mutators preserve it.
std::vector<VelocityPoint> points_;
CurveDomain domain_ = CurveDomain::Unipolar;
};
} // namespace reasampler::instrument::engine
+9 -6
View File
@@ -49,12 +49,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
// velocityGain_.
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
// amount both derive from it below).
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
sample_ = &sample;
const PlayParams& p = sample.play;
// Velocity->pitch is fixed for the note's lifetime, so it folds into baseRatio_ here rather
// than costing a per-frame multiply. Feeds both engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below).
velPitchRatio_ = velocityPitchRatio(p.pitchVelocityCurve, velocity);
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack) * velPitchRatio_;
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
@@ -129,8 +131,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
filterModAmount_ = p.filter.modAmount;
filterKeyTrack_ = p.filter.keyTrack;
filterVelOffset_ =
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterVelOffset_ = p.filter.velocityCurve.eval(static_cast<double>(velocity));
filterRate_ = static_cast<double>(sample.sampleRate);
rModAmount_.set(p.filter.modAmount);
rResonance_.set(static_cast<double>(p.filter.settings.resonanceNorm));
@@ -281,7 +282,9 @@ void Voice::retune(int note) {
// Changes baseRatio_ without re-converting pitchEnv_'s already-configured span (the
// baseRatio_ division in the note-on setup above), so a slide leaves that envelope on the
// first note's domain — consistent with "touch nothing else," but the drift lives here.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
// The velocity->pitch factor rides through the slide unchanged, matching velocityGain_ —
// one gesture, one strike.
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack) * velPitchRatio_;
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
if (filterOn_) updateFilterCutoffBase(note);
+11 -2
View File
@@ -48,6 +48,14 @@ inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
return std::pow(2.0, semis / 12.0);
}
// 2^(curve(velocity) * kVelocityPitchRangeSemitones / 12): the velocity->pitch transpose, which
// the voice folds into baseRatio_ once at note-on. A curve flat at 0 — the default — yields
// EXACTLY 1.0 at every velocity and skips the pow, so an undrawn curve transposes nothing.
inline double velocityPitchRatio(const VelocityCurve& curve, int velocity) {
const double semis = curve.eval(static_cast<double>(velocity)) * kVelocityPitchRangeSemitones;
return (semis == 0.0) ? 1.0 : std::pow(2.0, semis / 12.0);
}
// One octave expressed in the cutoff control's normalized domain, read out of the filter
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
@@ -561,7 +569,8 @@ private:
bool releasing_ = false;
int note_ = 0;
double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
double baseRatio_ = 1.0; // key-tracked repitch ratio, with velocity->pitch folded in
double velPitchRatio_ = 1.0; // the velocity->pitch factor alone; retune re-applies it
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;
@@ -593,7 +602,7 @@ private:
double filterRate_ = 0.0;
double filterCutoffNorm_ = 1.0;
double filterModAmount_ = 0.0;
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
double filterVelOffset_ = 0.0; // velocityCurve.eval(velocity), fixed per note
double filterKeyTrack_ = 0.0;
instrument::engine::filter::FilterSettings filterSettings_{}; // the note's tone controls
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
+24 -5
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v11) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v12) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -85,9 +85,23 @@ namespace reasampler::instrument::map {
// AHDSR's attack/decay/release curve exponents; the filter's Trigger AHD (same five fields as
// the amp's). A v9-or-older blob is a strict prefix and lifts to the neutral exponent 1.0.
//
// v11 (CURRENT WRITE FORMAT) is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE
// frames (a source-timeline quantity like the loop points, so no rate resolves it). A v10-or-
// older blob is a strict prefix and lifts to 0 — the hard seam it always played.
// v11 is v10 PLUS one 8-byte LE int64: the loop crossfade in SOURCE frames (a source-timeline
// quantity like the loop points, so no rate resolves it). A v10-or-older blob is a strict
// prefix and lifts to 0 — the hard seam it always played.
//
// v12 (CURRENT WRITE FORMAT) is v11 PLUS the velocity->PITCH transfer curve (count + points,
// the same shape as v7's), appended after the loop crossfade. It also RE-INTERPRETS two frozen
// slots inside the v9 filter tail — the byte shape is untouched, only the meaning at v12+:
// * the filter's velocity curve is now BIPOLAR [-1,+1] and is the whole velocity->cutoff
// amount, not a [0,1] shape scaled by a separate depth;
// * the retired filter velAmount slot is written as a constant 1.0 and ignored on read.
// PRE-v12 LIFT: the stored [0,1] filter curve has every knot's y multiplied by that blob's
// velAmount and is re-read as bipolar. eval is homogeneous in y, so the lifted curve evaluates
// to exactly velAmount * oldCurve(v) — the product the voice used to compute per note — and a
// pre-v12 project sounds identical. A pre-v12 blob carries no pitch curve at all and lifts to
// the bipolar flat-at-zero default, which transposes nothing. A DOWNGRADE to a pre-v12 binary
// reads the constant 1.0 depth against a curve whose negative half clamps away, so it
// reproduces the curve's positive half only.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
@@ -120,7 +134,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 11; // v10 + the loop-crossfade tail
inline constexpr std::uint32_t kParamsPayloadVersion = 12; // v11 + the velocity->pitch curve
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -139,6 +153,11 @@ inline constexpr std::uint32_t kParamsCurveVersion = 10;
// v10 + the loop-crossfade frame count.
inline constexpr std::uint32_t kParamsLoopVersion = 11;
// v11 + the velocity->pitch curve, and the version from which the filter's velocity curve is
// bipolar and self-scaling. Both the appended tail and the filter-tail lift branch on THIS,
// never on kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+40 -19
View File
@@ -38,14 +38,15 @@ void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
// The amp curve (v7) and the filter's own curve (v9) share this shape.
// A velocity curve: 4-byte LE control-point count, then per point velocity + value as doubles.
// The amp curve (v7), the filter's own curve (v9) and the pitch curve (v12) share this shape;
// the y DOMAIN is not on the wire — it is a property of the slot, so the reader supplies it.
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
putLE(out, doubleToBits(pt.value));
}
}
@@ -92,9 +93,12 @@ void readSecondsPlayTail(ByteReader& r, InstrumentParams& p, double projectRate)
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
// defensively; a truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
// Read a velocity curve tail into `curve`, interpreting its y values in `domain` and scaling
// them by `yScale` (the pre-v12 filter lift folds a retired depth in that way — see
// component_state_io.h). fromPoints repairs the X-order/endpoint invariant defensively; a
// truncated read leaves `curve` at whatever default it came in with.
void readCurveTail(ByteReader& r, VelocityCurve& curve,
reasampler::instrument::engine::CurveDomain domain, double yScale) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
@@ -103,17 +107,19 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve) {
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
const double value = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, value * yScale});
}
if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
}
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec.
void readFilterTail(ByteReader& r, InstrumentParams& p) {
// which is what makes a v8 blob play bit-identically under the new codec. `preVelocityVersion`
// selects the pre-v12 lift: the frozen velAmount slot is folded into the curve's knots instead
// of being kept as a separate depth.
void readFilterTail(ByteReader& r, InstrumentParams& p, bool preVelocityVersion) {
FilterSeconds& f = p.play.filter;
f.enabled = (r.u8() != 0);
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
@@ -122,20 +128,22 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) {
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
: engine::filter::MorphLaw::HighBandLow;
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these reach
// Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
double modAmount = bitsToDouble(r.u64());
double velAmount = bitsToDouble(r.u64());
double keyTrack = bitsToDouble(r.u64());
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
f.env.attackSeconds = bitsToDouble(r.u64());
f.env.holdSeconds = bitsToDouble(r.u64());
f.env.decaySeconds = bitsToDouble(r.u64());
f.env.sustainLevel = bitsToDouble(r.u64());
f.env.releaseSeconds = bitsToDouble(r.u64());
readCurveTail(r, f.velocityCurve);
const double velFold =
preVelocityVersion ? (std::isfinite(velAmount) ? velAmount : 0.0) : 1.0;
readCurveTail(r, f.velocityCurve, reasampler::instrument::engine::CurveDomain::Bipolar,
velFold);
}
// A curve exponent off the wire. A corrupt/non-finite value degrades to the LINEAR neutral
@@ -240,7 +248,10 @@ PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projec
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p.velocityCurve);
if (curveTail) {
readCurveTail(r, p.velocityCurve,
reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
}
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
@@ -299,7 +310,9 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
putLE(out, doubleToBits(f.modAmount));
putLE(out, doubleToBits(f.velAmount));
// The retired filter velAmount's frozen slot: a constant 1.0 so a pre-v12 binary reading
// this blob scales the curve by unity rather than silencing it (see component_state_io.h).
putLE(out, doubleToBits(1.0));
putLE(out, doubleToBits(f.keyTrack));
putLE(out, doubleToBits(f.env.attackSeconds));
putLE(out, doubleToBits(f.env.holdSeconds));
@@ -321,6 +334,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putAhd(out, f.trigEnv);
// v11: the loop crossfade, in SOURCE frames.
putLE(out, asU64(p.loopCrossfadeFrames));
// v12: the velocity->pitch curve.
putCurve(out, pp.pitchVelocityCurve);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -350,8 +365,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p, projectRate);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p.velocityCurve);
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
readCurveTail(r, p.velocityCurve, reasampler::instrument::engine::CurveDomain::Unipolar, 1.0);
if (pv >= kParamsFilterVersion) {
readFilterTail(r, p, /*preVelocityVersion=*/pv < kParamsVelocityVersion);
}
if (pv >= kParamsCurveVersion) readCurveStageTail(r, p);
if (pv >= kParamsLoopVersion) {
// A negative fade is meaningless and would reach resolveLoop's clamp anyway; refusing
@@ -359,6 +376,10 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
const std::int64_t xf = r.i64();
p.loopCrossfadeFrames = xf > 0 ? xf : 0;
}
if (pv >= kParamsVelocityVersion) {
readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar, 1.0);
}
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+1 -1
View File
@@ -238,12 +238,12 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
out.pitchEnv.shape = resolveAhd(stored.pitchEnv.shape);
out.pitchVelocityCurve = stored.pitchVelocityCurve; // transfer curve, not a time
// Filter: the control positions are already rate-free and carry through untouched; only
// its envelope resolves to frames.
out.filter.enabled = stored.filter.enabled;
out.filter.settings = stored.filter.settings;
out.filter.modAmount = stored.filter.modAmount;
out.filter.velAmount = stored.filter.velAmount;
out.filter.keyTrack = stored.filter.keyTrack;
out.filter.velocityCurve = stored.filter.velocityCurve;
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
+2 -2
View File
@@ -183,11 +183,10 @@ struct FilterSeconds {
bool enabled = false;
engine::filter::FilterSettings settings;
double modAmount = 0.0;
double velAmount = 0.0;
double keyTrack = 0.0;
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate
AhdSeconds trigEnv; // Trigger
VelocityCurve velocityCurve = VelocityCurve::linear();
VelocityCurve velocityCurve = VelocityCurve::zero();
};
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
@@ -200,6 +199,7 @@ struct PlaySeconds {
AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default
};
+3 -1
View File
@@ -57,4 +57,6 @@ reasampler_pure_library(deck_groups
reasampler_test(deck_groups LINK deck_groups sample_bands)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
reasampler_test(curve_popup LINK curve_popup)
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and
# proving that takes a curve of each domain mapped through the one curveBox.
reasampler_test(curve_popup LINK curve_popup velocity_curve)
+2 -1
View File
@@ -1,4 +1,5 @@
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor, shared
// by all three curves: the sheet is domain-agnostic, and only the curve's own y map differs.
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
// these rects.
//
+23 -2
View File
@@ -48,7 +48,6 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
id(DeckParam::kFilterQ),
id(DeckParam::kFilterDrive),
id(DeckParam::kFilterModAmt),
id(DeckParam::kFilterVel),
id(DeckParam::kFilterKeyTrack)};
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
out.push_back(std::move(filter));
@@ -89,6 +88,17 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
}
out.push_back(std::move(amp));
}
{
// The three velocity transfer curves share one home, immediately left of VOICE: they
// shape three different destinations but are one gesture, and MASTER is reserved for
// post-voice-mixer concerns.
DeckGroupDesc vel;
vel.id = kGroupVelocity;
vel.captionWidth = 54;
vel.cellIds = {id(DeckParam::kAmpVelCurve), id(DeckParam::kPitchVelCurve),
id(DeckParam::kFilterVelCurve)};
out.push_back(std::move(vel));
}
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
@@ -108,6 +118,15 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
return out;
}
CurveTarget curveTargetFor(int controlId) {
switch (static_cast<DeckParam>(controlId)) {
case DeckParam::kAmpVelCurve: return CurveTarget::kAmp;
case DeckParam::kPitchVelCurve: return CurveTarget::kPitch;
case DeckParam::kFilterVelCurve: return CurveTarget::kFilter;
default: return CurveTarget::kNone;
}
}
DeckParam curveParamFor(DeckParam knob) {
switch (knob) {
case DeckParam::kAttack: return DeckParam::kAttackCurve;
@@ -176,7 +195,9 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kPitchEnvEnable:
case DeckParam::kKeyTrack:
case DeckParam::kFilterEnable:
case DeckParam::kFilterVel:
case DeckParam::kAmpVelCurve:
case DeckParam::kPitchVelCurve:
case DeckParam::kFilterVelCurve:
case DeckParam::kFilterLaw:
case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect:
+15 -4
View File
@@ -40,7 +40,6 @@ enum class DeckParam {
kFilterQ, // resonance
kFilterDrive, // in-loop drive depth
kFilterModAmt, // filter envelope -> cutoff, +/-100%
kFilterVel, // velocity -> cutoff, +/-100%
kFilterKeyTrack, // note -> cutoff, 0..200%
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
kFilterEnvAttack, // filter AHDSR (Gate)
@@ -65,6 +64,11 @@ enum class DeckParam {
kFilterEnvReleaseCurve,
kFilterTrigAttackCurve,
kFilterTrigDecayCurve,
// VELOCITY: the three transfer-curve cells. Each opens the curve popup rather than
// dragging a value — see curveTargetFor, which is also what tells a cell apart from a knob.
kAmpVelCurve,
kPitchVelCurve,
kFilterVelCurve,
// Overlay selection radios — transient view state, not parameters.
kAmpEnvSelect,
kPitchEnvSelect,
@@ -86,10 +90,17 @@ enum DeckGroupId {
kGroupFilter,
kGroupFilterEnv,
kGroupAmpEnv,
kGroupVelocity,
kGroupVoice,
kGroupMaster,
};
// Which velocity curve a deck cell edits, or kNone when the control is an ordinary knob. THE
// one place the three curve cells are named, so paint (draw a curve thumbnail, not a dial),
// hit-test (open a popup, not start a drag) and the popup's own title all read from it.
enum class CurveTarget { kNone, kAmp, kPitch, kFilter };
CurveTarget curveTargetFor(int controlId);
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
// two instance-wide groups. `playMode` picks the AMP and FILTER ENV groups' faces — AHDSR in
// Gate, AHD in Trigger — via knob_deck's blank-cell reservation (knob_deck.h) so a mode flip
@@ -113,9 +124,9 @@ DeckParam curveParamFor(DeckParam knob);
// name a different sound rather than a different setting of one;
// - the three capture-anchored overrides (root, loop span, start frame) name positions in
// the decoded PCM;
// - kKeyTrack and kFilterVel feed values a voice latches at note-on by design (the pitch
// ratio and the velocity-curve result), so live delivery would retune or re-gain a note
// already struck;
// - kKeyTrack and the three velocity-curve cells feed values a voice latches at note-on by
// design (the pitch ratio and the curve results), so live delivery would retune or re-gain
// a note already struck;
// - kTrigLength resolves playEnd_, a fact about the note, not a setting of it;
// - the overlay radios select what the editor DRAWS and reach no parameter at all.
// Both amp shapes are live: the Trigger fade pair that used to reload folded into the AHD and
+27 -8
View File
@@ -19,12 +19,15 @@ constexpr int kStripBandHeight = 30;
constexpr int kRunGap = 6; // between adjacent items of the toolbar run
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
constexpr int kCurveBtnSize = 24;
constexpr int kVelCellW = 44;
constexpr int kVelLabelH = 12;
constexpr int kPreviewBtnW = 64;
constexpr int kRunButtonH = 24; // Browse and Preview
constexpr int kPreviewGlyphMinH = 6;
constexpr int kPreviewGlyphMaxH = 14;
constexpr int kPreviewGlyphPad = 4; // clearance between the glyph and the button edge
} // namespace
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
@@ -39,7 +42,8 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
// The fixed run, right to left: Browse, Mono|Stereo, curve, velocity cell, preview.
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview. The
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
const int navH = std::min(kRunButtonH, row.height);
const int navTop = topFor(navH);
const int navRight = std::max(row.x, row.right() - kPad);
@@ -53,14 +57,9 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
r.chanMono = Rect::ltrb(leftOf(r.chanStereo.x, kChanSegW), chanTop, r.chanStereo.x,
chanTop + kChanSegH);
const int curveTop = topFor(kCurveBtnSize);
const int curveRight = leftOf(r.chanMono.x, kRunGap);
r.curveBtn = Rect::ltrb(leftOf(curveRight, kCurveBtnSize), curveTop, curveRight,
curveTop + kCurveBtnSize);
const int cellH = std::min(row.height, knobSize + kVelLabelH);
const int cellTop = topFor(cellH);
const int cellRight = leftOf(r.curveBtn.x, kRunGap);
const int cellRight = leftOf(r.chanMono.x, kRunGap);
r.velCell = Rect::ltrb(leftOf(cellRight, kVelCellW), cellTop, cellRight, cellTop + cellH);
const int knobLeft = r.velCell.x + (r.velCell.width - knobSize) / 2;
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
@@ -89,4 +88,24 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
return r;
}
PreviewGlyph previewGlyph(const Rect& button) {
PreviewGlyph g;
if (button.empty()) return g;
// Even height so the apex lands exactly on the button's horizontal centre line rather
// than a half-pixel off it.
int h = std::min({kPreviewGlyphMaxH, button.height - 2 * kPreviewGlyphPad,
button.width - 2 * kPreviewGlyphPad});
h -= h % 2;
if (h < kPreviewGlyphMinH) return g;
const int w = std::max(2, (h * 7) / 8); // ~equilateral: the classic transport triangle
const int cx = button.x + button.width / 2;
const int cy = button.y + button.height / 2;
g.leftX = cx - w / 2;
g.apexX = g.leftX + w;
g.topY = cy - h / 2;
g.bottomY = g.topY + h;
g.apexY = cy;
return g;
}
} // namespace reasampler::instrument::ui
+16 -1
View File
@@ -21,7 +21,6 @@ struct ChromeRects {
Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob;
Rect velLabel;
Rect curveBtn; // opens the velocity-curve popup
Rect chanMono;
Rect chanStereo;
Rect navBrowse;
@@ -32,4 +31,20 @@ struct ChromeRects {
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
ChromeRects chromeRects(const Rect& chrome, int knobSize);
// A right-pointing play triangle centered in the preview button: the button's whole label.
// Three vertices, handed straight to one filled-triangle draw. Drawn rather than embedded as a
// bitmap so it inherits the palette role of whatever interaction state the button is in.
struct PreviewGlyph {
int leftX = 0; // the vertical edge
int topY = 0;
int bottomY = 0;
int apexX = 0; // the point, on the button's vertical centre line
int apexY = 0;
bool empty() const { return apexX <= leftX || bottomY <= topY; }
};
// Sized off the button's shorter dimension and clamped, so the glyph stays legible in a squat
// button and never outgrows a generous one. An unusably small button yields an empty glyph.
PreviewGlyph previewGlyph(const Rect& button);
} // namespace reasampler::instrument::ui