From 9d38f87a2de81d012ce4f017b2c33736f8d2e634 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 19:15:17 -0400 Subject: [PATCH] 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. --- src/core/instrument/CLAUDE.md | 18 +- src/core/instrument/engine/play_params.h | 29 +-- src/core/instrument/engine/velocity_curve.cpp | 111 ++++++----- src/core/instrument/engine/velocity_curve.h | 77 +++++--- src/core/instrument/engine/voice.cpp | 15 +- src/core/instrument/engine/voice.h | 13 +- src/core/instrument/map/component_state_io.h | 29 ++- src/core/instrument/map/params_payload.cpp | 59 ++++-- src/core/instrument/map/sample_map.cpp | 2 +- src/core/instrument/map/sample_map.h | 4 +- src/core/instrument/ui/CMakeLists.txt | 4 +- src/core/instrument/ui/curve_popup.h | 3 +- src/core/instrument/ui/deck_groups.cpp | 25 ++- src/core/instrument/ui/deck_groups.h | 19 +- src/core/instrument/ui/sample_chrome.cpp | 35 +++- src/core/instrument/ui/sample_chrome.h | 17 +- src/shell/instrument/editor_controls.cpp | 22 ++- src/shell/instrument/editor_input.cpp | 4 +- src/shell/instrument/editor_input_browse.cpp | 4 +- src/shell/instrument/editor_input_chrome.cpp | 11 +- src/shell/instrument/editor_input_curve.cpp | 26 +-- src/shell/instrument/editor_input_deck.cpp | 11 +- src/shell/instrument/editor_paint.cpp | 2 +- src/shell/instrument/editor_paint_chrome.cpp | 17 +- src/shell/instrument/editor_paint_curve.cpp | 85 +++++++-- src/shell/instrument/editor_paint_deck.cpp | 15 +- src/shell/instrument/editor_session.cpp | 2 +- src/shell/instrument/reasampler_editor.h | 22 ++- tests/test_component_state_io.cpp | 178 +++++++++++++---- tests/test_curve_popup.cpp | 37 ++++ tests/test_deck_groups.cpp | 91 +++++++-- tests/test_live_delivery.cpp | 9 +- tests/test_sample_chrome.cpp | 51 +++-- tests/test_sample_map.cpp | 28 ++- tests/test_sampler_core.cpp | 64 ++++++- tests/test_sampler_filter.cpp | 22 ++- tests/test_velocity_curve.cpp | 179 ++++++++++++++---- 37 files changed, 1020 insertions(+), 320 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index be210d7..80d8650 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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 (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `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 Fritsch–Carlson 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 Fritsch–Carlson 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. diff --git a/src/core/instrument/engine/play_params.h b/src/core/instrument/engine/play_params.h index a4f7de5..1cd6841 100644 --- a/src/core/instrument/engine/play_params.h +++ b/src/core/instrument/engine/play_params.h @@ -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; }; diff --git a/src/core/instrument/engine/velocity_curve.cpp b/src/core/instrument/engine/velocity_curve.cpp index 033cfa2..37a4d73 100644 --- a/src/core/instrument/engine/velocity_curve.cpp +++ b/src/core/instrument/engine/velocity_curve.cpp @@ -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(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(h - 1); + return (kCurveYMax - curveYMin(d)) / static_cast(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(frac * static_cast(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((1.0 - frac) * static_cast(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 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 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(x - box.left) / static_cast(w) * (kVelMax - kVelMin)); - p.amp = (h <= 1) - ? kAmpMax - : clampAmp(kAmpMax - static_cast(y - box.top) / static_cast(h - 1) * - (kAmpMax - kAmpMin)); + const double lo = curveYMin(domain_); + p.value = (h <= 1) + ? kCurveYMax + : clampValue(kCurveYMax - static_cast(y - box.top) / static_cast(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(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(dxPixels) * velPerPx; - // Y increases downward but amp increases upward, so a downward drag (positive dy) LOWERS amp. - const double newAmp = grab.amp - static_cast(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(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; } diff --git a/src/core/instrument/engine/velocity_curve.h b/src/core/instrument/engine/velocity_curve.h index fddfd77..12fd9b9 100644 --- a/src/core/instrument/engine/velocity_curve.h +++ b/src/core/instrument/engine/velocity_curve.h @@ -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 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 pts, CurveDomain domain); + CurveDomain domain() const { return domain_; } const std::vector& 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 points_; + CurveDomain domain_ = CurveDomain::Unipolar; }; } // namespace reasampler::instrument::engine diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index ea62534..72fb0f8 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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(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(p.filter.settings.cutoffNorm); filterModAmount_ = p.filter.modAmount; filterKeyTrack_ = p.filter.keyTrack; - filterVelOffset_ = - p.filter.velAmount * p.filter.velocityCurve.eval(static_cast(velocity)); + filterVelOffset_ = p.filter.velocityCurve.eval(static_cast(velocity)); filterRate_ = static_cast(sample.sampleRate); rModAmount_.set(p.filter.modAmount); rResonance_.set(static_cast(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); diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index 6de1e79..3637c47 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -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(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 diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 3268006..3472735 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 @@ -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 diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index f0a267d..a465735 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -38,14 +38,15 @@ void putOverrides(std::vector& 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& out, const VelocityCurve& curve) { const std::vector& pts = curve.points(); putLE(out, static_cast(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 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(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(bitsToDouble(r.u64())); @@ -122,20 +128,22 @@ void readFilterTail(ByteReader& r, InstrumentParams& p) { f.settings.driveNorm = static_cast(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, 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, 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& out, const InstrumentParams& p) putLE(out, doubleToBits(static_cast(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& 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{}; diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 6f8b84e..83184fa 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -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); diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 463d5ae..bc7fc9a 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -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 }; diff --git a/src/core/instrument/ui/CMakeLists.txt b/src/core/instrument/ui/CMakeLists.txt index 03d8ee8..5cf13ca 100644 --- a/src/core/instrument/ui/CMakeLists.txt +++ b/src/core/instrument/ui/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/ui/curve_popup.h b/src/core/instrument/ui/curve_popup.h index f9fb146..6530b79 100644 --- a/src/core/instrument/ui/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -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. // diff --git a/src/core/instrument/ui/deck_groups.cpp b/src/core/instrument/ui/deck_groups.cpp index 923c383..4b2517e 100644 --- a/src/core/instrument/ui/deck_groups.cpp +++ b/src/core/instrument/ui/deck_groups.cpp @@ -48,7 +48,6 @@ std::vector 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 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 sampleDeckGroups(PlayMode playMode) { return out; } +CurveTarget curveTargetFor(int controlId) { + switch (static_cast(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: diff --git a/src/core/instrument/ui/deck_groups.h b/src/core/instrument/ui/deck_groups.h index 355b4b2..4741a03 100644 --- a/src/core/instrument/ui/deck_groups.h +++ b/src/core/instrument/ui/deck_groups.h @@ -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 diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp index 52debb0..cd93273 100644 --- a/src/core/instrument/ui/sample_chrome.cpp +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -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 diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h index f67f3aa..57c30ba 100644 --- a/src/core/instrument/ui/sample_chrome.h +++ b/src/core/instrument/ui/sample_chrome.h @@ -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 diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index 5496665..868d1a7 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -11,6 +11,7 @@ #include #include // snprintf (deck value labels) #include +#include // std::as_const (the const/non-const editedCurve pair) #include #include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws @@ -132,7 +133,6 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const { case ParamControl::kFilterQ: return clamp01(play.filter.settings.resonanceNorm); case ParamControl::kFilterDrive: return clamp01(play.filter.settings.driveNorm); case ParamControl::kFilterModAmt: return deckNormFromBipolar(play.filter.modAmount); - case ParamControl::kFilterVel: return deckNormFromBipolar(play.filter.velAmount); case ParamControl::kFilterKeyTrack:return clamp01(play.filter.keyTrack / kKeyTrackMax); case ParamControl::kFilterEnvAttack: return secToNorm(play.filter.env.attackSeconds); case ParamControl::kFilterEnvHold: return secToNorm(play.filter.env.holdSeconds); @@ -215,7 +215,6 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value, case ParamControl::kFilterDrive: play.filter.settings.driveNorm = static_cast(clamp01(value)); break; case ParamControl::kFilterModAmt: play.filter.modAmount = deckBipolarFromNorm(value); break; - case ParamControl::kFilterVel: play.filter.velAmount = deckBipolarFromNorm(value); break; case ParamControl::kFilterKeyTrack: play.filter.keyTrack = clamp01(value) * kKeyTrackMax; break; case ParamControl::kFilterEnvAttack: @@ -363,8 +362,6 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const { break; case ParamControl::kFilterModAmt: snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.modAmount * 100.0); break; - case ParamControl::kFilterVel: - snprintf(buf, sizeof(buf), "%+.0f%%", play.filter.velAmount * 100.0); break; case ParamControl::kFilterKeyTrack: snprintf(buf, sizeof(buf), "%.0f%%", play.filter.keyTrack * 100.0); break; case ParamControl::kFilterEnvAttack: @@ -513,6 +510,23 @@ void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env } } +const VelocityCurve& ReaSamplerEditor::editedCurve() const { + switch (curvePopup_) { + case CurveTarget::kPitch: return params_.play.pitchVelocityCurve; + case CurveTarget::kFilter: return params_.play.filter.velocityCurve; + case CurveTarget::kAmp: + case CurveTarget::kNone: + // kNone only reaches here from a paint/hover racing the close; the amp curve is a + // valid, harmless read rather than a branch every caller would have to repeat. + return params_.velocityCurve; + } + return params_.velocityCurve; +} + +VelocityCurve& ReaSamplerEditor::editedCurve() { + return const_cast(std::as_const(*this).editedCurve()); +} + void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { if (id == static_cast(ParamControl::kKeyTrack)) { // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp index c9dff13..e89d4a3 100644 --- a/src/shell/instrument/editor_input.cpp +++ b/src/shell/instrument/editor_input.cpp @@ -121,7 +121,7 @@ void ReaSamplerEditor::onMouseUp(int x, int y) { y < curveRect.y - kCurveDragOffMargin || y > curveRect.bottom() + kCurveDragOffMargin; if (off) { - params_.velocityCurve.deletePoint(static_cast(curveIdx)); + editedCurve().deletePoint(static_cast(curveIdx)); hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node } } @@ -139,7 +139,7 @@ void ReaSamplerEditor::resolveHover(int x, int y) { HoverTarget h; // kNone by default if (view_ == View::kBrowse) { h = hoverBrowse(w, hgt, x, y); - } else if (curvePopupOpen_) { // modal over the face + } else if (curvePopup_ != CurveTarget::kNone) { // modal over the face h = hoverCurvePopup(w, hgt, x, y); } else { const FaceLayout fl = faceLayout(w, hgt); diff --git a/src/shell/instrument/editor_input_browse.cpp b/src/shell/instrument/editor_input_browse.cpp index ac4ab0e..d871d79 100644 --- a/src/shell/instrument/editor_input_browse.cpp +++ b/src/shell/instrument/editor_input_browse.cpp @@ -132,8 +132,8 @@ void ReaSamplerEditor::onMouseWheel(int delta) { void ReaSamplerEditor::onSearchChar(unsigned int ch) { // The curve popup: Esc dismisses (checked first — the popup is modal over the face, and // the Browse search cannot hold focus under it). - if (curvePopupOpen_ && ch == 27) { - curvePopupOpen_ = false; + if (curvePopup_ != CurveTarget::kNone && ch == 27) { + curvePopup_ = CurveTarget::kNone; invalidate(); return; } diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp index 57940fe..cb52885 100644 --- a/src/shell/instrument/editor_input_chrome.cpp +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -1,6 +1,6 @@ // editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger, -// the preview-velocity knob grab, the curve-button summon, the channel toggle, and the -// piano strip's root grab plus its live drag. Windows-only. +// the preview-velocity knob grab, the channel toggle, and the piano strip's root grab plus +// its live drag. Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -51,12 +51,6 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) { invalidate(); return true; } - // The mini curve-preview button: summon the popup editor. - if (contains(cr.curveBtn, x, y)) { - curvePopupOpen_ = true; - invalidate(); - return true; - } if (contains(cr.chanMono, x, y)) { channelMode_ = ChannelMode::Mono; processor_->setChannelMode(ChannelMode::Mono); @@ -105,7 +99,6 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1}; if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1}; - if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1}; if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1}; if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1}; if (!cr.rootStrip.empty()) { diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp index dbcd6b5..9a0686e 100644 --- a/src/shell/instrument/editor_input_curve.cpp +++ b/src/shell/instrument/editor_input_curve.cpp @@ -20,10 +20,10 @@ bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { // While open the sheet is modal over the face — it owns every left-click. Close click / // outside-wash click dismiss (outside only when no drag is in flight); in-box clicks // route to the curve machinery; anything else on the sheet is swallowed. - if (!curvePopupOpen_) return false; + if (curvePopup_ == CurveTarget::kNone) return false; const CurvePopupLayout pl = computeCurvePopup(w, h); if (contains(pl.close, x, y)) { - curvePopupOpen_ = false; + curvePopup_ = CurveTarget::kNone; invalidate(); return true; } @@ -32,7 +32,7 @@ bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { return true; } if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) { - curvePopupOpen_ = false; + curvePopup_ = CurveTarget::kNone; invalidate(); } return true; @@ -42,12 +42,12 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) { const VelocityCurve::Box box = curveBoxFromRect(r); if (box.width <= 0 || box.height <= 1) return; - int idx = params_.velocityCurve.pointAtPixel(box, x, y); + int idx = editedCurve().pointAtPixel(box, x, y); // Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once // (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { - if (params_.velocityCurve.deletePoint(static_cast(idx))) { + if (editedCurve().deletePoint(static_cast(idx))) { hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node commitAndReload(); } @@ -66,8 +66,8 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) { const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top && y < box.top + box.height); if (inBox) { - const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y); - idx = static_cast(params_.velocityCurve.addPoint(p.velocity, p.amp)); + const VelocityPoint p = editedCurve().pointFromPixel(box, x, y); + idx = static_cast(editedCurve().addPoint(p.velocity, p.value)); } } @@ -75,7 +75,7 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) { drag_ = DragKind::kCurveNode; curvePointIndex_ = idx; - dragStartCurve_ = params_.velocityCurve; // AFTER the add — resolvePointDrag's delta base + dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base dragCurveRect_ = r; dragStartX_ = x; dragStartY_ = y; @@ -87,7 +87,7 @@ void ReaSamplerEditor::dragCurve(int x, int y) { // (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute // delta — the mirror of the envelope-node drag). Live feedback only; commit on release. if (curvePointIndex_ < 0) return; - params_.velocityCurve = VelocityCurve::resolvePointDrag( + editedCurve() = VelocityCurve::resolvePointDrag( dragStartCurve_, static_cast(curvePointIndex_), curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_); invalidate(); @@ -98,16 +98,16 @@ void ReaSamplerEditor::onMouseRDown(int x, int y) { // and drag-off remain as landed alternates. Commits immediately through the same path as // Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op. // Right-clicks act only while the popup is open, and never during an in-flight left drag. - if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return; + if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return; if (drag_ != DragKind::kNone) return; RECT rc{}; GetClientRect(childHwnd_, &rc); const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); if (!contains(pl.curveBox, x, y)) return; const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); - const int idx = params_.velocityCurve.pointAtPixel(box, x, y); + const int idx = editedCurve().pointAtPixel(box, x, y); if (idx < 0) return; - if (params_.velocityCurve.deletePoint(static_cast(idx))) { + if (editedCurve().deletePoint(static_cast(idx))) { hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node commitAndReload(); } @@ -120,7 +120,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, in if (!contains(pl.curveBox, x, y)) return {}; // A curve node under the pointer lights accent-hot. const int idx = - params_.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); + editedCurve().pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); if (idx < 0) return {}; return {HoverKind::kCurveNode, idx}; } diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp index 811fa5b..8dd5aa0 100644 --- a/src/shell/instrument/editor_input_deck.cpp +++ b/src/shell/instrument/editor_input_deck.cpp @@ -28,8 +28,10 @@ bool ReaSamplerEditor::deckKnobDisabled(int id) const { case ParamControl::kFilterQ: case ParamControl::kFilterDrive: case ParamControl::kFilterModAmt: - case ParamControl::kFilterVel: case ParamControl::kFilterKeyTrack: + // The filter's velocity curve sits in the VELOCITY group but is a filter parameter: + // it goes inert with every other one, so no surface can reach a param the knobs can't. + case ParamControl::kFilterVelCurve: case ParamControl::kFilterEnvAttack: case ParamControl::kFilterEnvHold: case ParamControl::kFilterEnvDecay: @@ -96,6 +98,13 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { if (hit.kind == DeckHitKind::Knob) { // Knobs of a disabled group are drawn but inert. if (deckKnobDisabled(hit.id)) return true; + // A VELOCITY cell summons the popup editor instead of starting a knob drag. + const CurveTarget curveCell = curveTargetFor(hit.id); + if (curveCell != CurveTarget::kNone) { + curvePopup_ = curveCell; + invalidate(); + return true; + } // A grab on the inner disc drags the CURVE control instead, but only where the stage // is sloped; on a Hold or Sustain cell the inner region is just more of the knob. const ParamControl curve = curveParamFor(static_cast(hit.id)); diff --git a/src/shell/instrument/editor_paint.cpp b/src/shell/instrument/editor_paint.cpp index 9259507..2595326 100644 --- a/src/shell/instrument/editor_paint.cpp +++ b/src/shell/instrument/editor_paint.cpp @@ -70,7 +70,7 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { paintDeck(bmp, fl); // The curve popup: a centered sheet over the whole face, drawn last. - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); + if (curvePopup_ != CurveTarget::kNone) paintCurvePopup(bmp, w, h); // The piano strip's note-name chip overhangs its band, so it goes on top of everything. paintChromeTooltip(bmp, fl, w, h); diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp index e4528c6..4f1e9a9 100644 --- a/src/shell/instrument/editor_paint_chrome.cpp +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -129,12 +129,22 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool if (empty) return; // Preview-trigger button (fires the loaded capture at root through the live voice engine). + // A drawn play triangle rather than a label or an embedded image: it inherits the button's + // own foreground role, so it stays legible in every interaction state at no build cost. { const KitButtonBox box{toKitBox(cr.preview)}; - const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active + const bool active = (previewingNote_ >= 0); + const InteractionState st = active ? InteractionState::Active : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); - drawButton(bmp, box, "Preview", st, /*warn=*/false); + drawButton(bmp, box, nullptr, st, /*warn=*/false); + const PreviewGlyph g = previewGlyph(cr.preview); + if (!g.empty()) { + const LICE_pixel ink = + toLice(roleColor(active ? Role::BgBase : Role::TextPrimary)); + LICE_FillTriangle(bmp, g.leftX, g.topY, g.leftX, g.bottomY, g.apexX, g.apexY, + ink, 1.0f, 0); + } } // Preview velocity: a radial knob cell (the deck cell grammar), bound to the same @@ -156,9 +166,6 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool } } - // The mini curve-preview button: opens the popup editor. - paintCurveButton(bmp, cr.curveBtn); - // Mono | Stereo output-mode toggle. { const bool isStereo = (channelMode_ == ChannelMode::Stereo); diff --git a/src/shell/instrument/editor_paint_curve.cpp b/src/shell/instrument/editor_paint_curve.cpp index a1b7244..8988fd1 100644 --- a/src/shell/instrument/editor_paint_curve.cpp +++ b/src/shell/instrument/editor_paint_curve.cpp @@ -1,6 +1,6 @@ -// editor_paint_curve.cpp — the velocity->amp curve surfaces: the chrome band's mini -// preview button and the modal popup sheet that hosts the full editor. Band-independent -// (the popup floats over the whole face). Windows-only. +// editor_paint_curve.cpp — the velocity-curve surfaces: the VELOCITY deck group's mini +// thumbnails and the modal popup sheet that hosts the full editor. Band-independent (the +// popup floats over the whole face). Windows-only. #include "shell/instrument/reasampler_editor.h" @@ -15,28 +15,66 @@ namespace reasampler::vst { using namespace reasampler::ui; // kit vocabulary using namespace reasampler::instrument::ui; // popup geometry -void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r) { +namespace { + +// The curve a VELOCITY cell shows. Mirrors editedCurve's routing for the popup's own target; +// a cell draws whichever curve it opens, whether or not the popup is up. +const VelocityCurve& curveFor(const InstrumentParams& p, CurveTarget target) { + switch (target) { + case CurveTarget::kPitch: return p.play.pitchVelocityCurve; + case CurveTarget::kFilter: return p.play.filter.velocityCurve; + case CurveTarget::kAmp: + case CurveTarget::kNone: + return p.velocityCurve; + } + return p.velocityCurve; +} + +const char* curveTitle(CurveTarget target) { + switch (target) { + case CurveTarget::kPitch: return "VELOCITY -> PITCH"; + case CurveTarget::kFilter: return "VELOCITY -> FILTER"; + case CurveTarget::kAmp: + case CurveTarget::kNone: + return "VELOCITY -> AMP"; + } + return "VELOCITY -> AMP"; +} + +} // namespace + +void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, + bool disabled, bool hovered) { if (r.width <= 0 || r.height <= 0) return; // A hairline-bordered bg/cell square with the live velocity curve traced in miniature // (no node markers at this scale). Hover lifts it; it draws Active (accent-primary // border) while its popup is open, and re-renders live as the popup edits the curve. - const bool hov = isHovered(HoverKind::kCurveButton, -1); fillSurface(bmp, toKitBox(r), Role::BgCell, - hov ? InteractionState::Hover : InteractionState::Rest); - const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary) - : roleColor(Role::LineHairline); + disabled ? InteractionState::Disabled + : (hovered ? InteractionState::Hover : InteractionState::Rest)); + const KitColor border = (!disabled && curvePopup_ == target) + ? roleColor(Role::AccentPrimary) + : roleColor(Role::LineHairline); LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0); - const VelocityCurve& curve = params_.velocityCurve; + const VelocityCurve& curve = curveFor(params_, target); const int inset = 3; const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset, r.height - 2 * inset}; if (mini.width > 1 && mini.height > 1) { - const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary)); + // A bipolar thumbnail gets its zero line, without which a flat-at-zero curve and a + // flat-at-minimum one would draw identically at this scale. + if (curve.domain() == instrument::engine::CurveDomain::Bipolar) { + const int zy = curve.pixelFromPoint(mini, {0.0, 0.0}).y; + LICE_Line(bmp, mini.left, zy, mini.left + mini.width, zy, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + } + const LICE_pixel trace = + toLice(roleColor(disabled ? Role::LineHairline : Role::AccentSecondary)); int prevX = 0, prevY = 0; for (int px = 0; px <= mini.width; ++px) { const int mx = mini.left + px; - const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity; - const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y; + const double vel = curve.pointFromPixel(mini, mx, mini.top).velocity; + const int my = curve.pixelFromPoint(mini, {vel, curve.eval(vel)}).y; if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true); prevX = mx; prevY = my; @@ -52,7 +90,7 @@ void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) { fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest); LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1, pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); - kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim); + kitText(bmp, pl.title, curveTitle(curvePopup_), Font::Micro, Role::TextDim); { const KitButtonBox box{toKitBox(pl.close)}; const InteractionState st = isHovered(HoverKind::kPopupClose, -1) @@ -69,26 +107,33 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) { if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect) // The bordered box: a panel surface + hairline border, drawn by palette role. No corner - // caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup - // is the only host). + // caption — the popup sheet's own title labels this context (the popup is the only host). fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest); LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0); const VelocityCurve::Box box = curveBoxFromRect(r); if (box.width <= 0 || box.height <= 1) return; - const VelocityCurve& curve = params_.velocityCurve; + const VelocityCurve& curve = editedCurve(); + + // A bipolar editor needs its zero axis drawn: it is the whole "off" reading, and without + // it the default flat curve is indistinguishable from any other flat one. + if (curve.domain() == instrument::engine::CurveDomain::Bipolar) { + const int zy = curve.pixelFromPoint(box, {0.0, 0.0}).y; + LICE_Line(bmp, box.left, zy, box.left + box.width, zy, + toLice(roleColor(Role::LineHairline)), 1.0f, 0, false); + } // Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical // secondary accent (the same grammar as the envelope trace over the waveform). The x -> - // velocity and amp -> y mappings both go through the pure module so the trace, the node + // velocity and value -> y mappings both go through the pure module so the trace, the node // handles, and the hit-test all share one coordinate system. const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); int prevX = 0, prevY = 0; for (int px = 0; px <= box.width; ++px) { const int cx = box.left + px; - const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity; - const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y; + const double vel = curve.pointFromPixel(box, cx, box.top).velocity; + const int cy = curve.pixelFromPoint(box, {vel, curve.eval(vel)}).y; if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true); prevX = cx; prevY = cy; @@ -108,7 +153,7 @@ void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) { dragCurY_ < r.y - kCurveDragOffMargin || dragCurY_ > r.bottom() + kCurveDragOffMargin); for (std::size_t i = 0; i < curve.points().size(); ++i) { - const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]); + const auto np = curve.pixelFromPoint(box, curve.points()[i]); const bool grabbed = (drag_ == DragKind::kCurveNode && curvePointIndex_ == static_cast(i)); const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(i)); diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp index 14df8f2..cab8a3f 100644 --- a/src/shell/instrument/editor_paint_deck.cpp +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -76,8 +76,10 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case ParamControl::kFilterQ: return "Res"; case ParamControl::kFilterDrive: return "Drive"; case ParamControl::kFilterModAmt: return "Mod"; - case ParamControl::kFilterVel: return "Vel"; case ParamControl::kFilterKeyTrack: return "Key Trk"; + case ParamControl::kAmpVelCurve: return "Amp"; + case ParamControl::kPitchVelCurve: return "Pitch"; + case ParamControl::kFilterVelCurve: return "Filter"; case ParamControl::kFilterEnvAttack: return "F.Att"; case ParamControl::kFilterEnvHold: return "F.Hold"; case ParamControl::kFilterEnvDecay: return "F.Dec"; @@ -102,6 +104,7 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { case kGroupPitchEnv: caption = "PITCH ENV"; break; case kGroupFilter: caption = "FILTER"; break; case kGroupFilterEnv: caption = "FILTER ENV"; break; + case kGroupVelocity: caption = "VELOCITY"; break; case kGroupVoice: caption = "VOICE"; break; case kGroupMaster: caption = "MASTER"; break; default: break; @@ -169,6 +172,16 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) { for (const DeckCellLayout& c : g.cells) { if (c.id < 0) continue; // reserved blank cell (the Trigger face's spare) const bool disabled = deckKnobDisabled(c.id); + // A VELOCITY cell is a popup opener, not a dial: it shows its curve in miniature + // where a knob face would be, and its whole cell is the click target. + const CurveTarget curveCell = curveTargetFor(c.id); + if (curveCell != CurveTarget::kNone) { + paintCurveButton(bmp, c.knob, curveCell, disabled, + !disabled && isHovered(HoverKind::kControl, c.id)); + kitTextCentered(bmp, c.label, knobName(static_cast(c.id)), + Font::Micro, disabled ? Role::LineHairline : Role::TextDim); + continue; + } const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id); const bool hov = !disabled && isHovered(HoverKind::kControl, c.id); const InteractionState st = diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 3f9eab3..518ef6c 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -72,7 +72,7 @@ void ReaSamplerEditor::refreshFromBank() { monoTrigger_ = processor_->monoTrigger(); // A refresh that emptied the selection closes the curve popup — an open-but-invisible // modal would otherwise swallow clicks on the empty state. - if (selectedId_.empty()) curvePopupOpen_ = false; + if (selectedId_.empty()) curvePopup_ = CurveTarget::kNone; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { bool found = false; diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 376a5a4..797d549 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -86,6 +86,9 @@ private: // spelling. using OverlayEnv = instrument::ui::OverlayEnv; + // Which of the three velocity curves a deck cell edits — also the popup's open state. + using CurveTarget = instrument::ui::CurveTarget; + // Controls on the setup surface. The int value is the opaque control id the pure // knob_deck hit-test returns; the shell maps it to the one parameter set or a // processor-side per-instance setter. The id space and the deck's group composition are @@ -120,7 +123,6 @@ private: kCurveNode, // a velocity-curve control point (index = point index) kVelKnob, // the chrome preview-velocity radial knob kStripKey, // a piano-strip key (index = MIDI note); carries the name tooltip - kCurveButton, // the chrome mini curve-preview button (opens the popup) kPopupClose, // the curve popup's Close (x) button }; struct HoverTarget { @@ -159,11 +161,12 @@ private: // label<->value swap on hover/drag. void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl); - // The mini curve-preview button (chrome) and the modal curve editor it summons. - void paintCurveButton(LICE_IBitmap* bmp, const Rect& r); + // A deck cell's mini curve thumbnail (the VELOCITY group) and the modal editor it summons. + void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, CurveTarget target, bool disabled, + bool hovered); void paintCurvePopup(LICE_IBitmap* bmp, int w, int h); - // The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only - // host is the popup sheet. `r` empty -> draws nothing. + // The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its + // only host is the popup sheet. `r` empty -> draws nothing. void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r); // Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at // full band height (never per lane). @@ -456,8 +459,13 @@ private: // delta from this anchor, so a grab never jumps the value. double dragKnobStartValue_ = 0.0; - // Curve popup open flag, never persisted. - bool curvePopupOpen_ = false; + // Which velocity curve the popup is editing; kNone = closed. Never persisted. + CurveTarget curvePopup_ = CurveTarget::kNone; + // The parameter-set curve `curvePopup_` names. Both overloads exist because every edit + // path needs the mutable one and paint needs the const one; routing through this ONE + // switch is what keeps the three curves on a single popup/draw/hit-test code path. + VelocityCurve& editedCurve(); + const VelocityCurve& editedCurve() const; // Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize // recomputes at the new width. Cleared on refresh so a stale sample never shows. diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 813428a..fbeb72f 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -12,6 +12,7 @@ #include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap) #include "../src/core/util/curve_law.h" // kCurveNeutral (the migration neutral) +#include #include #include #include @@ -134,7 +135,7 @@ static void putRecordBody(std::vector& out, const Zone& z, std::ui const std::vector pts = z.curve.empty() ? std::vector{{0.0, 1.0}, {127.0, 1.0}} : z.curve; u32v(out, static_cast(pts.size())); - for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); } + for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.value); } } } @@ -281,7 +282,8 @@ static void testComponentStateRoundTrip() { in.params.startPoint = 5; in.params.keyTrack = 1.5; in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( - {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}); + {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}, + reasampler::instrument::engine::CurveDomain::Unipolar); in.params.play.playMode = PlayMode::Trigger; in.params.play.adsr.attackSeconds = 0.01; in.params.play.adsr.holdSeconds = 0.05; @@ -422,7 +424,8 @@ static void testGoldenFullBlobFixture() { in.params.startPoint = 250; in.params.keyTrack = 0.5; in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( - {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}); + {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}, + reasampler::instrument::engine::CurveDomain::Unipolar); in.params.play.playMode = PlayMode::Trigger; in.params.play.adsr.attackSeconds = 0.01; in.params.play.adsr.holdSeconds = 0.05; @@ -450,7 +453,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0b,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0c,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -472,18 +475,18 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // driveNorm 0.0 0x00, // morphLaw = HighBandLow 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // modAmount 0.0 - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velAmount 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // velAmount slot: frozen constant 1.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // keyTrack 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env attack 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env hold 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env decay 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // env sustain 1.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // env release 0.0 - 0x02,0x00,0x00,0x00, // filter curve: 2 points (linear) + 0x02,0x00,0x00,0x00, // filter curve: 2 points (flat at zero) 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0 - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // amp 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 - 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp 1.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 // --- payload v10 staged-curve tail, at its NEUTRAL default (this fixture sets no // curve or AHD field), in the header's documented order --- 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // amp attack curve 1.0 @@ -507,6 +510,12 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0 // --- payload v11 loop-crossfade tail --- 0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256 + // --- payload v12 velocity->pitch curve, at its off default (flat at zero) --- + 0x02,0x00,0x00,0x00, // 2 points + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // velocity 0.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 + 0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // velocity 127.0 + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -554,16 +563,17 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 11); + CHECK(kParamsPayloadVersion == 12); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two - // axes stay independent, so a future envelope field cannot collide with any of them on one - // number. + // The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones + // — the two axes stay independent, so a future envelope field cannot collide with any of + // them on one number. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion); - CHECK(kParamsPayloadVersion == kParamsLoopVersion); + CHECK(kParamsVelocityVersion > kParamsLoopVersion); + CHECK(kParamsPayloadVersion == kParamsVelocityVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -601,7 +611,7 @@ static void testV8RecordLiftsToTheOffNeutralFilter() { CHECK(f.settings.driveNorm == def.settings.driveNorm); CHECK(f.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighBandLow); CHECK(f.modAmount == 0.0); - CHECK(f.velAmount == 0.0); + for (int v = 0; v <= 127; ++v) CHECK(f.velocityCurve.eval(v) == 0.0); CHECK(f.keyTrack == 0.0); CHECK(f.env.sustainLevel == 1.0); CHECK(f.env.attackSeconds == 0.0 && f.env.decaySeconds == 0.0 && @@ -628,15 +638,17 @@ static void testFilterTailRoundTripsLosslessly() { f.settings.driveNorm = 0.5f; f.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow; f.modAmount = -0.625; - f.velAmount = 0.5; f.keyTrack = 1.5; f.env.attackSeconds = 0.031; f.env.holdSeconds = 0.062; f.env.decaySeconds = 0.125; f.env.sustainLevel = 0.25; f.env.releaseSeconds = 0.5; + // Bipolar, and reaching into the negative half the retired unipolar shape could not + // express: a codec that read this back through the old domain would clamp it to 0. f.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( - {VelocityPoint{0.0, 0.1}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}}); + {VelocityPoint{0.0, -0.75}, VelocityPoint{100.0, 0.4}, VelocityPoint{127.0, 0.9}}, + reasampler::instrument::engine::CurveDomain::Bipolar); // The amp's own curve stays different, so a codec that read one into the other fails here. in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::flat(); @@ -650,7 +662,6 @@ static void testFilterTailRoundTripsLosslessly() { CHECK(g.settings.driveNorm == f.settings.driveNorm); CHECK(g.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow); CHECK(g.modAmount == f.modAmount); - CHECK(g.velAmount == f.velAmount); CHECK(g.keyTrack == f.keyTrack); CHECK(g.env.attackSeconds == f.env.attackSeconds); CHECK(g.env.holdSeconds == f.env.holdSeconds); @@ -658,11 +669,41 @@ static void testFilterTailRoundTripsLosslessly() { CHECK(g.env.sustainLevel == f.env.sustainLevel); CHECK(g.env.releaseSeconds == f.env.releaseSeconds); CHECK(g.velocityCurve.size() == 3); - CHECK(g.velocityCurve.equals(f.velocityCurve)); + CHECK(g.velocityCurve.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); + CHECK(g.velocityCurve.eval(0.0) == -0.75); // the negative half survives the round trip + CHECK(g.velocityCurve.eval(100.0) == 0.4); + CHECK(g.velocityCurve.eval(127.0) == 0.9); CHECK(out.params.velocityCurve.equals( reasampler::instrument::engine::VelocityCurve::flat())); } +// The velocity->PITCH curve (payload v12) is a third, independent slot: it round-trips whole, +// and neither of the other two leaks into it. +static void testPitchVelocityCurveRoundTripsIndependently() { + ComponentState in; + in.selectionId = "pad"; + in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, -1.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 0.5}}, + reasampler::instrument::engine::CurveDomain::Bipolar); + in.params.play.filter.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.2}, VelocityPoint{127.0, -0.6}}, + reasampler::instrument::engine::CurveDomain::Bipolar); + in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::linear(); + + const ComponentState out = + deserializeComponentState(serializeComponentState(in), 48000.0); + const reasampler::instrument::engine::VelocityCurve& p = out.params.play.pitchVelocityCurve; + CHECK(p.size() == 3); + CHECK(p.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); + CHECK(p.eval(0.0) == -1.0); + CHECK(p.eval(64.0) == 0.25); + CHECK(p.eval(127.0) == 0.5); + // The other two slots kept their own values — no cross-talk between the three curves. + CHECK(out.params.play.filter.velocityCurve.eval(127.0) == -0.6); + CHECK(out.params.velocityCurve.eval(127.0) == 1.0); + CHECK(out.params.velocityCurve.eval(0.0) == 0.0); +} + // A non-finite modAmount/velAmount/keyTrack (a corrupt blob, or any writer that skipped the // same guard the v8 master gain already applies) must lift to the neutral default rather than // reach Voice::tickFilterCutoff, where both clamp compares are false against NaN and the @@ -673,7 +714,6 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() { FilterSeconds& f = in.params.play.filter; f.enabled = true; f.modAmount = std::numeric_limits::quiet_NaN(); - f.velAmount = std::numeric_limits::infinity(); f.keyTrack = -std::numeric_limits::infinity(); const ComponentState out = @@ -681,7 +721,6 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() { const FilterSeconds& g = out.params.play.filter; const FilterSeconds def; CHECK(g.modAmount == def.modAmount); - CHECK(g.velAmount == def.velAmount); CHECK(g.keyTrack == def.keyTrack); // The fallback is per-field, not per-record: the untouched fields still round-trip. CHECK(g.enabled); @@ -747,6 +786,7 @@ static void testNegativeCrossfadeOnTheWireLiftsToZero() { // output with version N stamped in and the (N+1..current) tails cut. Building the older blobs // that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped // being a pure suffix, these would decode as garbage instead of as the documented lift. +static const std::size_t kVelocityTailBytes = 4 + 2 * 2 * 8; // v12: the 2-pt pitch curve static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64 static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles static const std::size_t kFilterTailBytes = @@ -775,8 +815,30 @@ static std::vector payloadDowngradedTo(const ComponentState& state return bytes; } -// A project saved before this change reopens sounding identical: its loop span still applies -// and its seam is still hard, at EVERY prior single-record version. +// Overwrite the frozen filter velAmount slot: the writer emits a constant 1.0 there now, so a +// pre-v12 fixture has to plant its own depth. Located by the DISTINCT modAmount immediately +// preceding it rather than by a byte offset, so a tail growing ahead of it cannot rot this. +static void plantPreV12FilterDepth(std::vector& bytes, double modAmount, + double velAmount) { + std::vector needle; + legacy::f64v(needle, modAmount); + std::size_t at = 0; + int hits = 0; + for (std::size_t i = 0; i + 2 * needle.size() <= bytes.size(); ++i) { + if (std::equal(needle.begin(), needle.end(), bytes.begin() + static_cast(i))) { + at = i; + ++hits; + } + } + CHECK(hits == 1); // an ambiguous anchor would plant the depth in the wrong slot + std::vector depth; + legacy::f64v(depth, velAmount); + for (std::size_t k = 0; k < depth.size(); ++k) bytes[at + needle.size() + k] = depth[k]; +} + +// A project saved before this change reopens sounding identical: its loop span still applies, +// its seam is still hard, and velocity still modulates pitch not at all, at EVERY prior +// single-record version. static void testPriorPayloadVersionsLiftToAHardSeam() { ComponentState in; in.selectionId = "pad"; @@ -792,6 +854,11 @@ static void testPriorPayloadVersionsLiftToAHardSeam() { // it — proving the cuts land where the ladder says they do. in.params.play.adsr.attackCurve = 4.0; in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11 + // Present in the bytes only at v12: an off-default pitch curve, so a lift that leaked one + // in from anywhere else fails rather than coincidentally matching the default. + in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.5}, VelocityPoint{127.0, 1.0}}, + reasampler::instrument::engine::CurveDomain::Bipolar); struct Case { std::uint32_t pv; @@ -799,9 +866,10 @@ static void testPriorPayloadVersionsLiftToAHardSeam() { bool keepsCurveTail; }; const Case cases[] = { - {10, kLoopTailBytes, true}, - {9, kLoopTailBytes + kCurveTailBytes, false}, - {8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false}, + {11, kVelocityTailBytes, true}, + {10, kVelocityTailBytes + kLoopTailBytes, true}, + {9, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes, false}, + {8, kVelocityTailBytes + kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false}, }; for (const Case& c : cases) { const ComponentState out = @@ -813,14 +881,57 @@ static void testPriorPayloadVersionsLiftToAHardSeam() { CHECK(out.params.startPoint && *out.params.startPoint == 128); CHECK(out.params.keyTrack == 0.5); CHECK(out.params.play.adsr.releaseSeconds == 0.25); - // The documented pre-change behaviour: a hard seam. - CHECK(out.params.loopCrossfadeFrames == 0); + // The documented pre-change behaviour: a hard seam and no velocity->pitch at all. + CHECK(out.params.loopCrossfadeFrames == (c.pv >= 11 ? 777 : 0)); + for (int v = 0; v <= 127; ++v) { + CHECK(out.params.play.pitchVelocityCurve.eval(v) == 0.0); + } // And the cut landed on the tail boundary the ladder claims, not somewhere inside it. CHECK(out.params.play.adsr.attackCurve == (c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral)); } } +// The sharp edge of the bipolar change: a pre-v12 blob stored the filter's velocity response +// as a [0,1] SHAPE times a separate depth, and the lift folds that depth into the knots. The +// lifted curve must evaluate to exactly the product the pre-change voice computed. +static void testPreV12FilterVelocityDepthFoldsIntoTheCurve() { + ComponentState in; + in.selectionId = "pad"; + FilterSeconds& f = in.params.play.filter; + f.enabled = true; + f.modAmount = -0.6251953125; // distinct and exactly representable: the planting anchor + // What an old blob's curve looked like: a shape confined to [0,1], with the sign and the + // amount living in the depth beside it. + const reasampler::instrument::engine::VelocityCurve shape = + reasampler::instrument::engine::VelocityCurve::fromPoints( + {VelocityPoint{0.0, 0.0}, VelocityPoint{64.0, 0.25}, VelocityPoint{127.0, 1.0}}, + reasampler::instrument::engine::CurveDomain::Bipolar); + f.velocityCurve = shape; + + for (const double depth : {-0.75, 0.5, 0.0}) { + std::vector bytes = + payloadDowngradedTo(in, kParamsLoopVersion, kVelocityTailBytes); + plantPreV12FilterDepth(bytes, f.modAmount, depth); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + const reasampler::instrument::engine::VelocityCurve& lifted = + out.params.play.filter.velocityCurve; + CHECK(lifted.domain() == reasampler::instrument::engine::CurveDomain::Bipolar); + for (int v = 0; v <= 127; ++v) { + CHECK(std::fabs(lifted.eval(v) - depth * shape.eval(v)) < 1e-12); + } + CHECK(out.params.play.filter.modAmount == f.modAmount); // the anchor itself survives + } + + // At v12 the same slot is ignored: the curve is read verbatim, whatever sits in it. + std::vector current = serializeComponentState(in); + plantPreV12FilterDepth(current, f.modAmount, 0.0); + const ComponentState now = deserializeComponentState(current, 48000.0); + for (int v = 0; v <= 127; ++v) { + CHECK(std::fabs(now.params.play.filter.velocityCurve.eval(v) - shape.eval(v)) < 1e-12); + } +} + // The WRITER emits the CURRENT payload version, and the marker + version sit at the head of // the payload — the self-describing property every legacy branch depends on. Asserted // against the semantic constants, not literals. @@ -1306,11 +1417,12 @@ static void testSampleRefsTruncatedMidEntry() { // The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") + // the current params payload for DEFAULT params (marker4+version4 + overrides3 + the // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte - // v9 filter tail + the 160-byte v10 staged-curve tail) = 452 bytes; entry two is 47 bytes - // (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0). Cutting 472 keeps the first - // 27 of entry two's 47 — mid loop.start (offset 23..31). - CHECK(bytes.size() > 472); - bytes.resize(bytes.size() - 472); + // v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the + // 36-byte v12 pitch curve) = 488 bytes; entry two is 47 bytes (id 4+3, path 4+7, root4, + // loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 — + // mid loop.start (offset 23..31). + CHECK(bytes.size() > 508); + bytes.resize(bytes.size() - 508); const ComponentState back = deserializeComponentState(bytes, 44100.0); CHECK(back.sampleRefs.size() == 1); CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick"); @@ -1393,6 +1505,7 @@ int main() { testLoopSpanAndCrossfadeRoundTrip(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); + testPreV12FilterVelocityDepthFoldsIntoTheCurve(); testWriterEmitsCurrentPayloadVersion(); testSingleZoneMigrationIsLossless(); testMigratedFadeContourTracksTheRetiredEqualPowerShape(); @@ -1415,6 +1528,7 @@ int main() { testTruncationDegradesCleanly(); testV8RecordLiftsToTheOffNeutralFilter(); testFilterTailRoundTripsLosslessly(); + testPitchVelocityCurveRoundTripsIndependently(); testNonFiniteFilterFieldsLiftToTheNeutralDefault(); testNonFiniteAhdSecondsLiftToZero(); if (failures == 0) { diff --git a/tests/test_curve_popup.cpp b/tests/test_curve_popup.cpp index 262e560..b85a07f 100644 --- a/tests/test_curve_popup.cpp +++ b/tests/test_curve_popup.cpp @@ -6,10 +6,14 @@ #include "../src/core/instrument/ui/curve_popup.h" +#include "../src/core/instrument/engine/velocity_curve.h" + #include using namespace reasampler; using namespace reasampler::instrument::ui; +using reasampler::instrument::engine::CurveDomain; +using reasampler::instrument::engine::VelocityCurve; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ @@ -80,7 +84,40 @@ static void testOutsideSheetDismissTest() { CHECK(!popupOutsideSheet(pl, pl.sheet.right() - 1, pl.sheet.bottom() - 1)); } +// The sheet hosts all three curves, and its box is domain-agnostic: the SAME curveBox drives a +// unipolar and a bipolar editor, and only the curve's own y map differs. Asserted here, against +// the popup's real geometry, because that is what makes one popup code path legitimate. +static void testTheSameCurveBoxHostsBothDomains() { + const CurvePopupLayout pl = computeCurvePopup(840, 620); + // The shell insets this rect before mapping; the inset is uniform, so any box inside the + // curveBox exercises the same relationship. Use the rect itself. + const VelocityCurve::Box box{pl.curveBox.x, pl.curveBox.y, pl.curveBox.width, + pl.curveBox.height}; + CHECK(box.width > 1 && box.height > 1); + + const VelocityCurve amp = VelocityCurve::flat(); + const VelocityCurve mod = VelocityCurve::zero(); + const int top = box.top; + const int bottom = box.top + box.height - 1; + + // Both domains put their MAX on the top row and their MIN on the bottom row... + CHECK(amp.pixelFromPoint(box, {0.0, 1.0}).y == top); + CHECK(amp.pixelFromPoint(box, {0.0, 0.0}).y == bottom); + CHECK(mod.pixelFromPoint(box, {0.0, 1.0}).y == top); + CHECK(mod.pixelFromPoint(box, {0.0, -1.0}).y == bottom); + // ...so value 0 is the FLOOR for the amp curve and the MIDLINE for a modulation curve. + CHECK(mod.pixelFromPoint(box, {0.0, 0.0}).y == (top + bottom) / 2); + + // And a click at the vertical centre adds a point at 0 in the bipolar editor, at 0.5 in + // the unipolar one — one hit-test path, two correct answers. + const int midY = (top + bottom) / 2; + CHECK(mod.pointFromPixel(box, box.left, midY).value == 0.0); + CHECK(amp.pointFromPixel(box, box.left, midY).value > 0.49); + CHECK(amp.pointFromPixel(box, box.left, midY).value < 0.51); +} + int main() { + testTheSameCurveBoxHostsBothDomains(); testDefaultWindowMidClamp(); testMinClamp(); testMaxClamp(); diff --git a/tests/test_deck_groups.cpp b/tests/test_deck_groups.cpp index 3b66f4f..96117bb 100644 --- a/tests/test_deck_groups.cpp +++ b/tests/test_deck_groups.cpp @@ -1,7 +1,9 @@ // Standalone tests for reasampler::instrument::ui::deck_groups — no VST3, no REAPER, no // framework. knob_deck's own tests pin how a descriptor list LAYS OUT; these pin WHICH // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), -// the Filter group's contents, the wrapped deck height at the editor's floor width and its fit +// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve +// cells and its placement immediately left of VOICE, the wrapped deck height at the editor's +// floor width and its fit // inside the floor window, the hit-test reaching the new filter controls, the bipolar knob // law's inverse pair, the commit-tier routing — which controls are live, and which drags take // the live tier — and the overlay-selection state machine (exclusivity, the none resting state, @@ -48,20 +50,77 @@ static void testDeckReadsPitchThenFilterThenAmpLeftToRight() { CHECK(penv < filt); CHECK(filt < fenv); CHECK(fenv < amp); - // The two instance-wide groups stay at the end. - CHECK(amp < indexOfGroup(g, kGroupVoice)); + // VELOCITY then the two instance-wide groups at the end. Velocity sits IMMEDIATELY + // left of VOICE — MASTER is reserved for post-voice-mixer concerns, so the curves + // must not drift into it. + const int vel = indexOfGroup(g, kGroupVelocity); + CHECK(amp < vel); + CHECK(vel + 1 == indexOfGroup(g, kGroupVoice)); CHECK(indexOfGroup(g, kGroupVoice) < indexOfGroup(g, kGroupMaster)); } } -static void testFilterGroupCarriesItsFiveToneControlsPlusModulation() { +// The three velocity curves live together in VELOCITY and nowhere else: no other group may +// carry a curve cell, or the "one home" the group exists for is not one. +static void testVelocityGroupOwnsTheThreeCurvesExclusively() { + for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) { + const std::vector g = sampleDeckGroups(mode); + const DeckGroupDesc& v = + g[static_cast(indexOfGroup(g, kGroupVelocity))]; + const std::vector expected = {cell(DeckParam::kAmpVelCurve), + cell(DeckParam::kPitchVelCurve), + cell(DeckParam::kFilterVelCurve)}; + CHECK(v.cellIds == expected); + CHECK(v.captionToggle.id == -1 && v.rowToggle.id == -1 && v.captionRadio.id == -1); + for (const DeckGroupDesc& d : g) { + if (d.id == kGroupVelocity) continue; + for (int id : d.cellIds) CHECK(curveTargetFor(id) == CurveTarget::kNone); + CHECK(curveTargetFor(d.captionToggle.id) == CurveTarget::kNone); + CHECK(curveTargetFor(d.rowToggle.id) == CurveTarget::kNone); + } + } +} + +// Each curve cell names its OWN destination, and an ordinary knob names none — the predicate +// the shell uses to tell a popup opener from a dial. +static void testCurveTargetNamesEachCellsOwnDestination() { + CHECK(curveTargetFor(cell(DeckParam::kAmpVelCurve)) == CurveTarget::kAmp); + CHECK(curveTargetFor(cell(DeckParam::kPitchVelCurve)) == CurveTarget::kPitch); + CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter); + CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone); + CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone); + CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell + CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space +} + +// The cells hit-test inside their own group, from the centre of each cell — the deck grammar +// treats them as knob cells, so the popup routing rides an ordinary Knob hit. +static void testVelocityCellsHitTestWithinTheirGroup() { + const std::vector g = sampleDeckGroups(PlayMode::Gate); + const DeckLayout dl = layoutDeck(g, kPad, 40, kAvailAtMinWidth); + const DeckGroupLayout& v = + dl.groups[static_cast(indexOfGroup(g, kGroupVelocity))]; + CHECK(v.cells.size() == 3); + const CurveTarget want[] = {CurveTarget::kAmp, CurveTarget::kPitch, CurveTarget::kFilter}; + for (std::size_t i = 0; i < v.cells.size(); ++i) { + const DeckCellLayout& c = v.cells[i]; + const DeckHit hit = hitTestDeck(dl, c.cell.x + c.cell.width / 2, + c.cell.y + c.cell.height / 2); + CHECK(hit.kind == DeckHitKind::Knob); + CHECK(hit.id == c.id); + CHECK(curveTargetFor(hit.id) == want[i]); + // Inside its own group box, and the cell the hit resolved is this one. + CHECK(c.cell.x >= v.box.x && c.cell.right() <= v.box.right()); + } +} + +static void testFilterGroupCarriesItsToneControlsPlusModulation() { const std::vector& g = sampleDeckGroups(PlayMode::Gate); const DeckGroupDesc& f = g[static_cast(indexOfGroup(g, kGroupFilter))]; const std::vector expected = { cell(DeckParam::kFilterMorph), cell(DeckParam::kFilterCutoff), cell(DeckParam::kFilterQ), cell(DeckParam::kFilterDrive), - cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterVel), - cell(DeckParam::kFilterKeyTrack)}; + cell(DeckParam::kFilterModAmt), cell(DeckParam::kFilterKeyTrack)}; CHECK(f.cellIds == expected); // Off by default is a state question, but reachability is a layout one: the enable // toggle is in the caption row and the morph law in the knob row. @@ -177,10 +236,12 @@ static void testAmpGroupWidthSurvivesAGateTriggerFlip() { static void testWrappedDeckHeightAtTheEditorFloorWidth() { const std::vector g = sampleDeckGroups(PlayMode::Gate); - // At the floor (== default) 840 the deck takes two rows: PITCH + PITCH ENV + FILTER fill - // the first, the remaining four fit the second. - CHECK(deckRowCount(g, kAvailAtMinWidth) == 2); - CHECK(deckHeight(g, kAvailAtMinWidth) == 2 * kDeckGroupH + kDeckRowGap); + // At the floor (== default) 840 the deck takes three rows: PITCH + PITCH ENV + FILTER fill + // the first, FILTER ENV + AMP + VELOCITY the second, VOICE + MASTER the third. The eight + // groups total more than two rows can hold at this width — the VELOCITY group's three + // cells are ~150 px more than the FILTER group gave back when its velocity depth retired. + CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); + CHECK(deckHeight(g, kAvailAtMinWidth) == 3 * kDeckGroupH + 2 * kDeckRowGap); // Whole groups only, never split: every group's box lies inside the available width or is // the first of its row. @@ -224,7 +285,7 @@ static void testHitTestResolvesTheNewFilterControls() { CHECK(hit.kind == DeckHitKind::Knob); CHECK(hit.id == c.id); } - CHECK(f.cells.size() == 7); + CHECK(f.cells.size() == 6); CHECK(f.cells[1].id == cell(DeckParam::kFilterCutoff)); // The enable toggle's two segments and the morph-law row toggle's two. @@ -298,7 +359,8 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() { // is excluded. const DeckParam reloads[] = { DeckParam::kPlayMode, DeckParam::kPitchEngine, DeckParam::kPitchEnvEnable, - DeckParam::kFilterEnable, DeckParam::kFilterLaw, DeckParam::kFilterVel, + DeckParam::kFilterEnable, DeckParam::kFilterLaw, + DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kVoiceCount, DeckParam::kVoiceMode, @@ -405,7 +467,10 @@ int main() { testEveryDeckControlIsClassifiedLiveOrReloading(); testOnlyALiveControlsDragTakesTheLiveTier(); testDeckReadsPitchThenFilterThenAmpLeftToRight(); - testFilterGroupCarriesItsFiveToneControlsPlusModulation(); + testVelocityGroupOwnsTheThreeCurvesExclusively(); + testCurveTargetNamesEachCellsOwnDestination(); + testVelocityCellsHitTestWithinTheirGroup(); + testFilterGroupCarriesItsToneControlsPlusModulation(); testOnlyTheThreeEnvelopeDecksCarryARadio(); testGateAndTriggerFacesCarryTheirOwnShapes(); testOnlySlopedStageKnobsCarryAnInnerCurveDial(); diff --git a/tests/test_live_delivery.cpp b/tests/test_live_delivery.cpp index c7448a8..5d5831b 100644 --- a/tests/test_live_delivery.cpp +++ b/tests/test_live_delivery.cpp @@ -786,14 +786,13 @@ static void testPitchRatioAndVelocityGainStayLatched() { static void testVelocityGainSurvivesAHostilePublishThatReallyLands() { // Filter AND pitch envelope enabled, so every field the block carries actually reaches the - // voice. velAmount is 0, so velocity enters the render exactly once — as the amp gain - // latched at note-on — which makes two runs at different velocities exactly proportional - // unless the publish moved that gain (a re-derived gain would have to preserve the ratio - // 100:64 to slip through). + // voice. The filter and pitch velocity curves are left at their off defaults, so velocity + // enters the render exactly once — as the amp gain latched at note-on — which makes two + // runs at different velocities exactly proportional unless the publish moved that gain (a + // re-derived gain would have to preserve the ratio 100:64 to slip through). SampleData rig = periodicSine(200000, 64.0); rig.velocityCurve = VelocityCurve::linear(); filterSweep(rig); - rig.play.filter.velAmount = 0.0; rig.play.pitchEnv.enabled = true; rig.play.pitchEnv.shape.decayFrames = 24000; rig.play.pitchEnv.peakSemitones = 3.0; diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp index 5ad0eb8..ed00485 100644 --- a/tests/test_sample_chrome.cpp +++ b/tests/test_sample_chrome.cpp @@ -2,10 +2,11 @@ // test framework. // // Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the -// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button, -// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its +// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo, +// Browse) with the title taking the remainder; the velocity knob centred in its // cell above its label; the piano strip owning its whole row at every width; no rect on the -// toolbar overlapping any other; and degenerate bands yielding no inverted rects. +// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview +// button's play-triangle glyph, which sits inside the button without changing its rect. #include "../src/core/instrument/ui/sample_bands.h" #include "../src/core/instrument/ui/sample_chrome.h" @@ -48,20 +49,19 @@ static void testRowsTileTheBandExactly() { static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() { const Rect band = chromeBand(); const ChromeRects r = chromeRects(band, kKnob); - // Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title. + // Rightmost first: Browse, stereo, mono, velocity cell, preview, title. CHECK(r.navBrowse.right() == band.right() - kPad); CHECK(r.navBrowse.width == kNavButtonWidth); CHECK(r.chanStereo.right() <= r.navBrowse.x); CHECK(r.chanMono.right() == r.chanStereo.x); - CHECK(r.curveBtn.right() <= r.chanMono.x); - CHECK(r.velCell.right() <= r.curveBtn.x); + CHECK(r.velCell.right() <= r.chanMono.x); CHECK(r.preview.right() <= r.velCell.x); CHECK(r.title.right() <= r.preview.x); CHECK(r.title.x == band.x + kPad); CHECK(r.title.width > 0); // Every toolbar rect sits inside the toolbar row. - const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono, + const Rect items[] = {r.title, r.preview, r.velCell, r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom()); @@ -75,7 +75,7 @@ static void testChromePartsNeverOverlapAtAnyWidth() { // stay inside its own row, clear of every control. CHECK(!overlaps(r.toolbar, r.rootStrip)); CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom()); - const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo, + const Rect items[] = {r.preview, r.velCell, r.chanMono, r.chanStereo, r.navBrowse}; for (const Rect& it : items) { CHECK(!overlaps(it, r.rootStrip)); @@ -83,8 +83,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() { } // The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell, // so they are checked against the cell's neighbours, not the cell). - for (int i = 0; i < 6; ++i) { - for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j])); + for (int i = 0; i < 5; ++i) { + for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j])); } } } @@ -127,11 +127,38 @@ static void testDegenerateBandYieldsNoInvertedRects() { const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight), kKnob); const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel, - tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse, + tiny.chanMono, tiny.chanStereo, tiny.navBrowse, tiny.rootStrip}; for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y); } +static void testPreviewGlyphSitsInsideTheButtonAndPointsRight() { + const ChromeRects r = chromeRects(chromeBand(), kKnob); + const PreviewGlyph g = previewGlyph(r.preview); + CHECK(!g.empty()); + // Wholly inside the button — the glyph replaces the label, it does not resize the target. + CHECK(g.leftX >= r.preview.x && g.apexX <= r.preview.right()); + CHECK(g.topY >= r.preview.y && g.bottomY <= r.preview.bottom()); + // Right-pointing, and the apex on the button's own centre line so it reads as balanced. + CHECK(g.apexX > g.leftX); + CHECK(g.apexY == r.preview.y + r.preview.height / 2); + CHECK(g.apexY - g.topY == g.bottomY - g.apexY); // isosceles about the centre line + // The button's rect is what the hit-test uses, and the glyph must not have moved it: the + // preview button still sits at the run's fixed size, exactly where the text button did. + CHECK(r.preview.width == 64 && r.preview.height == 24); +} + +static void testPreviewGlyphDegradesRatherThanOverflowing() { + // An unusably small button yields an empty glyph (draw nothing) rather than a triangle + // spilling past the button edge. + CHECK(previewGlyph(Rect{}).empty()); + CHECK(previewGlyph(Rect::ltrb(0, 0, 6, 6)).empty()); + // A tall, wide button caps the glyph instead of scaling without bound. + const PreviewGlyph big = previewGlyph(Rect::ltrb(0, 0, 400, 200)); + CHECK(!big.empty()); + CHECK(big.bottomY - big.topY <= 14); +} + static void testToolbarOnlyBandStillPlacesTheNav() { // A band clipped to just the toolbar row: the strip row is empty but Browse still // resolves, so the empty state's call-to-action is never unreachable. @@ -149,6 +176,8 @@ int main() { testStripOwnsItsWholeRowAndGrowsWithTheWindow(); testVelocityKnobIsCentredInItsCellAboveTheLabel(); testDegenerateBandYieldsNoInvertedRects(); + testPreviewGlyphSitsInsideTheButtonAndPointsRight(); + testPreviewGlyphDegradesRatherThanOverflowing(); testToolbarOnlyBandStillPlacesTheNav(); if (g_fail == 0) { diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 4cd78b6..4c91b9a 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -638,7 +638,8 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() { st.filter.settings.driveNorm = 0.125f; st.filter.settings.morphLaw = reasampler::instrument::engine::filter::MorphLaw::HighNotchLow; st.filter.modAmount = -0.5; - st.filter.velAmount = 0.25; + st.filter.velocityCurve = VelocityCurve::fromPoints( + {{0.0, 0.0}, {127.0, 0.25}}, reasampler::instrument::engine::CurveDomain::Bipolar); st.filter.keyTrack = 1.25; st.filter.env.attackSeconds = 0.01; st.filter.env.holdSeconds = 0.02; @@ -654,7 +655,11 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() { CHECK(at48.filter.settings.driveNorm == 0.125f); CHECK(at48.filter.settings.morphLaw == reasampler::instrument::engine::filter::MorphLaw::HighNotchLow); CHECK(at48.filter.modAmount == -0.5); - CHECK(at48.filter.velAmount == 0.25); + // The transfer curve is dimensionless, so it crosses unchanged — asserted against the + // straight line the two stored knots describe, not against the stored object. + CHECK(at48.filter.velocityCurve.eval(0.0) == 0.0); + CHECK(approx(at48.filter.velocityCurve.eval(127.0), 0.25)); + CHECK(approx(at48.filter.velocityCurve.eval(63.5), 0.125)); CHECK(at48.filter.keyTrack == 1.25); CHECK(at48.filter.env.attackFrames == 480); CHECK(at48.filter.env.holdFrames == 960); @@ -672,9 +677,25 @@ static void testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope() { const PlayParams bare = resolvePlay(PlaySeconds{}, 48000); CHECK(!bare.filter.enabled); CHECK(bare.filter.modAmount == 0.0); - CHECK(bare.filter.velAmount == 0.0); + for (int v = 0; v <= 127; ++v) CHECK(bare.filter.velocityCurve.eval(v) == 0.0); CHECK(bare.filter.keyTrack == 0.0); CHECK(bare.filter.env.sustainLevel == 1.0); + // The velocity->pitch curve rides the same boundary and is off by the same default. + for (int v = 0; v <= 127; ++v) CHECK(bare.pitchVelocityCurve.eval(v) == 0.0); +} + +// The velocity->pitch curve is dimensionless like the filter's, so resolvePlay carries it +// across the seconds->frames boundary untouched at any rate. +static void testResolvePlayCarriesThePitchVelocityCurve() { + PlaySeconds st; + st.pitchVelocityCurve = VelocityCurve::fromPoints( + {{0.0, -1.0}, {127.0, 1.0}}, reasampler::instrument::engine::CurveDomain::Bipolar); + for (const int rate : {44100, 96000}) { + const PlayParams p = resolvePlay(st, rate); + CHECK(p.pitchVelocityCurve.eval(0.0) == -1.0); + CHECK(p.pitchVelocityCurve.eval(127.0) == 1.0); + CHECK(approx(p.pitchVelocityCurve.eval(63.5), 0.0)); + } } static void testResolvePlayRoundsAndFloorsNegatives() { @@ -937,6 +958,7 @@ int main() { testLegacyLiftDecision(); testResolvePlayConvertsWallClockAtTheRate(); testResolvePlayCarriesTheFilterAndResolvesOnlyItsEnvelope(); + testResolvePlayCarriesThePitchVelocityCurve(); testResolvePlayRoundsAndFloorsNegatives(); testMigratedFadeStretchesWhenTheDecodeRateDiffersFromTheProjectRate(); testResolveCaptureUsesIntrinsicsWhenNoOverride(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index 3e9683f..00a6762 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -1766,7 +1766,8 @@ static SampleData twoLevelSample() { km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0}, {static_cast(kVelLow), 0.25}, {static_cast(kVelHigh), 0.75}, - {127.0, 1.0}}); + {127.0, 1.0}}, + instrument::engine::CurveDomain::Unipolar); return km; } @@ -1966,6 +1967,65 @@ static SampleData rampSample(std::size_t frames, int rootNote) { return s; } +// --- velocity -> pitch --------------------------------------------------------- +// +// A ramp sample reads its own position, so the value on frame N IS the accumulated read rate: +// the transpose is directly observable rather than inferred from a spectrum. + +static void testVelocityPitchIsExactlyOffByDefault() { + // The modulation VALUE at every velocity, not a rendered approximation of it: the default + // bipolar curve must yield the identity ratio exactly, so nothing detunes by a hair. + const PlayParams def; + for (int v = 0; v <= 127; ++v) { + CHECK(velocityPitchRatio(def.pitchVelocityCurve, v) == 1.0); + } + // And at the render: two strikes of very different velocity read the ramp identically. + SampleData s = rampSample(4096, 60); + Voice soft; + Voice hard; + soft.start(60, 1, s); + hard.start(60, 127, s); + for (int i = 0; i < 64; ++i) CHECK(soft.renderFrame() == hard.renderFrame()); +} + +static void testDrawnVelocityPitchCurveTransposesBothWays() { + using instrument::engine::CurveDomain; + SampleData s = rampSample(4096, 60); + const double full = std::pow(2.0, kVelocityPitchRangeSemitones / 12.0); + + // A curve pinned at +1 across the domain: every velocity transposes UP by the full scale. + s.play.pitchVelocityCurve = + VelocityCurve::fromPoints({{0.0, 1.0}, {127.0, 1.0}}, CurveDomain::Bipolar); + Voice up; + up.start(60, 100, s); + CHECK(approx(static_cast(up.renderFrame()), 0.0, 1e-9)); + CHECK(approx(static_cast(up.renderFrame()), full, 1e-4)); + + // Pinned at -1: DOWN by the same scale — the half of the domain the old unipolar curve + // could not express at all. + s.play.pitchVelocityCurve = + VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, -1.0}}, CurveDomain::Bipolar); + Voice down; + down.start(60, 100, s); + down.renderFrame(); + CHECK(approx(static_cast(down.renderFrame()), 1.0 / full, 1e-4)); + + // And it follows the curve: a rising ramp gives a soft hit less transpose than a hard one. + s.play.pitchVelocityCurve = + VelocityCurve::fromPoints({{0.0, 0.0}, {127.0, 1.0}}, CurveDomain::Bipolar); + Voice q; + Voice f; + q.start(60, 20, s); + f.start(60, 120, s); + q.renderFrame(); + f.renderFrame(); + const double quiet = static_cast(q.renderFrame()); + const double loud = static_cast(f.renderFrame()); + CHECK(quiet > 1.0); + CHECK(loud > quiet); + CHECK(loud < full); // velocity 120 is short of the +1 endpoint +} + // MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up. // Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a // legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and @@ -2801,6 +2861,8 @@ int main() { testRepitchObservedPeriod(); testKeyTrackVarispeedObservedPeriod(); testKeyTrackPreserveShiftCollapsesAtZero(); + testVelocityPitchIsExactlyOffByDefault(); + testDrawnVelocityPitchCurveTransposesBothWays(); testAdsrShape(); testAdsrReleaseBeforeSustain(); testAdsrZeroAttackDecay(); diff --git a/tests/test_sampler_filter.cpp b/tests/test_sampler_filter.cpp index ebd2a3c..1915917 100644 --- a/tests/test_sampler_filter.cpp +++ b/tests/test_sampler_filter.cpp @@ -116,7 +116,8 @@ static void testDisengagedFilterIsBitInertEvenWithExtremeSettingsStored() { stored.play.filter.enabled = false; stored.play.filter.settings.driveNorm = 1.0f; stored.play.filter.modAmount = 1.0; - stored.play.filter.velAmount = -1.0; + stored.play.filter.velocityCurve = VelocityCurve::fromPoints( + {{0.0, 0.0}, {127.0, -1.0}}, instrument::engine::CurveDomain::Bipolar); stored.play.filter.keyTrack = 2.0; const std::vector inert = render(stored, 60, 100, 1500); for (std::size_t i = 0; i < inert.size(); ++i) CHECK(inert[i] == bare[i]); @@ -329,18 +330,21 @@ static void testAnUnmodulatedVoiceIsBitIdenticalToASinglePreparedFilter() { static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() { // Playback key-tracking off, so both notes read the source at the SAME rate and the only // note-dependent difference left is the filter's own key-tracking. - const auto tone = [](double velAmount, double keyTrack) { + const auto tone = [](double velTop, double keyTrack) { SampleData s = periodicSine(8000, 64); s.play.adsr = flatAdsr(); s.keyTrack = 0.0; s.play.filter = engagedFilter(0.25f, 0.0f, 1.0f); - s.play.filter.velAmount = velAmount; + if (velTop != 0.0) { + s.play.filter.velocityCurve = VelocityCurve::fromPoints( + {{0.0, 0.0}, {127.0, velTop}}, instrument::engine::CurveDomain::Bipolar); + } s.play.filter.keyTrack = keyTrack; return s; }; - // Velocity: the default linear curve rises with velocity, so a positive depth opens the - // filter for a hard hit. The amp's own velocity curve is flat, so amplitude is unaffected. + // Velocity: a curve rising to +1 opens the filter for a hard hit. The amp's own velocity + // curve is flat, so amplitude is unaffected. SampleData vel = tone(1.0, 0.0); const std::vector soft = render(vel, 60, 1, 6000); const std::vector hard = render(vel, 60, 127, 6000); @@ -352,7 +356,8 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() { const std::vector high = render(key, 84, 100, 6000); CHECK(rms(high, 2000, 6000) > 2.0 * rms(low, 2000, 6000)); - // Both neutral: neither velocity nor note may move the filter. + // Both neutral: neither velocity nor note may move the filter. `tone(0,0)` leaves the + // DEFAULT velocity curve, so this is the off-by-default contract itself. SampleData neutral = tone(0.0, 0.0); const std::vector a = render(neutral, 60, 1, 6000); const std::vector b = render(neutral, 60, 127, 6000); @@ -361,6 +366,11 @@ static void testVelocityAndKeyTrackingReachCutoffAndAreNoOpsAtTheirDefaults() { CHECK(a[i] == b[i]); CHECK(a[i] == c[i]); } + // And the modulation VALUE itself is exactly zero at every velocity, not merely small + // enough that the render came out equal — the render check alone would still pass under a + // cutoff offset too small to survive the coefficient solve's float rounding. + const FilterParams def; + for (int v = 0; v <= 127; ++v) CHECK(def.velocityCurve.eval(v) == 0.0); } static void testNoteOnResetsTheFilterSoAPreviousNoteCannotLeak() { diff --git a/tests/test_velocity_curve.cpp b/tests/test_velocity_curve.cpp index 7e642cc..50c3fc0 100644 --- a/tests/test_velocity_curve.cpp +++ b/tests/test_velocity_curve.cpp @@ -1,20 +1,24 @@ // Standalone tests for reasampler::instrument::engine::velocity_curve — no VST3, no REAPER, no framework. Same fast -// assert loop as the sibling pure tests. Assert the S-VIEW-9 velocity->amp transfer curve HARD: +// assert loop as the sibling pure tests. Assert the velocity transfer curve HARD: // -// * eval — flat y=1 default (R10-F1 Option A: EVERY velocity -> 1.0), linear ramp, curved shape +// * eval — flat y=1 unipolar default (EVERY velocity -> 1.0), linear ramp, curved shape // between points, box-clamp of an out-of-range velocity, monotonic-in-x over the whole domain. +// * the BIPOLAR domain — zero() is exactly 0 everywhere, the negative half evaluates and clamps +// at -1, eval is homogeneous in y (what the pre-v12 filter lift rests on), and the pixel maps +// put value 0 on the box's centre line rather than its floor. // * editing — addPoint keeps X-order + box-clamp; movePoint clamps an interior point between its -// neighbours (can't cross) and box-clamps amp; endpoints are X-pinned (velocity 0 / 127) with -// only amp mobile; deletePoint removes interior points but REFUSES the two endpoints. +// neighbours (can't cross) and box-clamps the value; endpoints are X-pinned (velocity 0 / 127) +// with only the value mobile; deletePoint removes interior points but REFUSES the two endpoints. // * hit-test + inverse map — pointAtPixel grabs a drawn node; resolvePointDrag maps pixel delta to // a clamped point (endpoint X-pinned; interior clamped to neighbours); degenerate box -> no motion. // * fromPoints — the deserialization repair: sorts by X, box-clamps, forces endpoints, and falls -// back to flat() for a sub-2-point list. +// back to each domain's OWN neutral for a sub-2-point list. #include "../src/core/instrument/engine/velocity_curve.h" #include #include +#include using namespace reasampler; using namespace reasampler::instrument::engine; @@ -27,6 +31,17 @@ static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b using Box = VelocityCurve::Box; +// The pixel maps are members (the y domain lives on the curve), so a mapping test speaks +// through a curve of the domain under test rather than a free function. +static const VelocityCurve& uni() { + static const VelocityCurve c = VelocityCurve::flat(); + return c; +} +static const VelocityCurve& bip() { + static const VelocityCurve c = VelocityCurve::zero(); + return c; +} + // --- eval --------------------------------------------------------------------- static void testFlatIsUnityEverywhere() { @@ -151,11 +166,11 @@ static void testAddPointKeepsXOrderAndClamps() { const std::size_t i = c.addPoint(60.0, 0.3); CHECK(i == 1); // inserted between the two endpoints CHECK(c.size() == 3); - CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].amp, 0.3)); + CHECK(near(c.points()[1].velocity, 60.0) && near(c.points()[1].value, 0.3)); // Out-of-box add clamps into [0,127] x [0,1]. c.addPoint(500.0, 5.0); const VelocityPoint& last = c.points().back(); - CHECK(near(last.velocity, 127.0) && near(last.amp, 1.0)); + CHECK(near(last.velocity, 127.0) && near(last.value, 1.0)); // Points remain X-ordered. for (std::size_t k = 1; k < c.size(); ++k) CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity); @@ -171,7 +186,7 @@ static void testMoveInteriorClampsToNeighbours() { // Try to drag idx 1 PAST idx 2 (velocity 200): clamps to idx 2's velocity (80), not beyond. const VelocityPoint r = c.movePoint(1, 200.0, 0.5); CHECK(near(r.velocity, 80.0)); - CHECK(near(r.amp, 0.5)); // amp is free (box-clamped only) + CHECK(near(r.value, 0.5)); // amp is free (box-clamped only) // Try to drag idx 1 BELOW idx 0 (velocity -5): clamps to idx 0's velocity (0). const VelocityPoint r2 = c.movePoint(1, -5.0, 0.5); CHECK(near(r2.velocity, 0.0)); @@ -182,18 +197,18 @@ static void testMoveEndpointsArePinnedInX() { // Move the first endpoint: velocity argument ignored (pinned at 0), amp moves. const VelocityPoint f = c.movePoint(0, 50.0, 0.25); CHECK(near(f.velocity, 0.0)); - CHECK(near(f.amp, 0.25)); + CHECK(near(f.value, 0.25)); // Move the last endpoint: pinned at 127, amp moves, and amp box-clamps. const VelocityPoint l = c.movePoint(1, 10.0, 5.0); CHECK(near(l.velocity, 127.0)); - CHECK(near(l.amp, 1.0)); + CHECK(near(l.value, 1.0)); } static void testMoveOutOfRangeIndexIsNoOp() { VelocityCurve c = VelocityCurve::linear(); c.movePoint(99, 50.0, 0.5); CHECK(c.size() == 2); - CHECK(near(c.points()[0].amp, 0.0) && near(c.points()[1].amp, 1.0)); // unchanged + CHECK(near(c.points()[0].value, 0.0) && near(c.points()[1].value, 1.0)); // unchanged } // --- editing: deletePoint ----------------------------------------------------- @@ -237,11 +252,11 @@ static void testResolveDragMovesAndClamps() { // Drag idx 1 right 10px, up 10px: velocity +10 (->70), amp +0.10 (up = higher amp -> 0.60). const VelocityCurve moved = VelocityCurve::resolvePointDrag(grab, 1, b, 10, -10); CHECK(near(moved.points()[1].velocity, 70.0, 1e-6)); - CHECK(near(moved.points()[1].amp, 0.60, 1e-6)); + CHECK(near(moved.points()[1].value, 0.60, 1e-6)); // Dragging the first endpoint horizontally does not move it in X (pinned), only amp. const VelocityCurve movedEnd = VelocityCurve::resolvePointDrag(grab, 0, b, 40, -20); CHECK(near(movedEnd.points()[0].velocity, 0.0)); - CHECK(near(movedEnd.points()[0].amp, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0 + CHECK(near(movedEnd.points()[0].value, 0.20, 1e-6)); // dragged up 20px = +0.20 from 0 } static void testResolveDragDegenerateBoxNoMotion() { @@ -255,7 +270,7 @@ static void testResolveDragDegenerateBoxNoMotion() { static void testFromPointsSortsClampsAndForcesEndpoints() { // Unsorted, out-of-box, missing endpoints -> repaired to a valid curve. std::vector raw = {{80.0, 0.9}, {20.0, -1.0}, {50.0, 2.0}}; - const VelocityCurve c = VelocityCurve::fromPoints(raw); + const VelocityCurve c = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar); // X-ordered. for (std::size_t k = 1; k < c.size(); ++k) CHECK(c.points()[k - 1].velocity <= c.points()[k].velocity); @@ -264,15 +279,101 @@ static void testFromPointsSortsClampsAndForcesEndpoints() { CHECK(near(c.points().back().velocity, 127.0)); // Interior amps box-clamped (the -1 became 0, the 2 became 1). for (const VelocityPoint& p : c.points()) { - CHECK(p.amp >= 0.0 - 1e-12 && p.amp <= 1.0 + 1e-12); + CHECK(p.value >= 0.0 - 1e-12 && p.value <= 1.0 + 1e-12); } } static void testFromPointsSubTwoFallsBackToFlat() { - const VelocityCurve c0 = VelocityCurve::fromPoints({}); + const VelocityCurve c0 = VelocityCurve::fromPoints({}, CurveDomain::Unipolar); CHECK(c0.equals(VelocityCurve::flat())); - const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}); + const VelocityCurve c1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Unipolar); CHECK(c1.equals(VelocityCurve::flat())); + // The bipolar fallback is the domain's OWN neutral, not the unipolar one: degrading a + // corrupt pitch/filter curve to flat-at-unity would transpose or open the filter fully. + const VelocityCurve b0 = VelocityCurve::fromPoints({}, CurveDomain::Bipolar); + CHECK(b0.equals(VelocityCurve::zero())); + const VelocityCurve b1 = VelocityCurve::fromPoints({{50.0, 0.3}}, CurveDomain::Bipolar); + CHECK(b1.equals(VelocityCurve::zero())); +} + +// --- the bipolar domain ------------------------------------------------------- + +static void testZeroIsExactlyZeroAtEveryVelocity() { + // The off-by-default contract: not "approximately zero" — EXACTLY zero, so a pitch or + // cutoff offset derived from it cannot nudge anything. + const VelocityCurve c = VelocityCurve::zero(); + CHECK(c.domain() == CurveDomain::Bipolar); + for (int v = -20; v <= 200; ++v) CHECK(c.eval(v) == 0.0); + CHECK(c.size() == 2); +} + +static void testBipolarEvalSpansTheNegativeHalf() { + // A ramp from -1 at velocity 0 to +1 at 127: collinear knots, so the spline is the exact + // straight line through zero — the whole point of the widened domain. + const VelocityCurve c = + VelocityCurve::fromPoints({{0.0, -1.0}, {127.0, 1.0}}, CurveDomain::Bipolar); + CHECK(near(c.eval(0), -1.0)); + CHECK(near(c.eval(127), 1.0)); + CHECK(near(c.eval(63.5), 0.0, 1e-12)); + for (int v = 0; v <= 127; ++v) CHECK(near(c.eval(v), 2.0 * v / 127.0 - 1.0, 1e-12)); +} + +static void testUnipolarClampsAtZeroWhereBipolarDoesNot() { + // The same negative knot, read in the two domains: unipolar floors it at 0 (an amp gain + // cannot be negative), bipolar keeps it. + const std::vector raw = {{0.0, -0.5}, {127.0, 0.5}}; + const VelocityCurve u = VelocityCurve::fromPoints(raw, CurveDomain::Unipolar); + const VelocityCurve b = VelocityCurve::fromPoints(raw, CurveDomain::Bipolar); + CHECK(near(u.eval(0), 0.0)); + CHECK(near(b.eval(0), -0.5)); + // Out-of-domain magnitudes clamp to each domain's own floor. + const VelocityCurve b2 = + VelocityCurve::fromPoints({{0.0, -9.0}, {127.0, 9.0}}, CurveDomain::Bipolar); + CHECK(near(b2.eval(0), -1.0)); + CHECK(near(b2.eval(127), 1.0)); +} + +static void testEvalIsHomogeneousInY() { + // The property the pre-v12 filter lift rests on: scaling every knot's y by k scales the + // whole evaluated curve by k. Asserted against a CURVED (non-collinear) knot set, where + // the Fritsch-Carlson tangents are actually doing work. + const std::vector knots = { + {0.0, 0.1}, {30.0, 0.15}, {64.0, 0.9}, {100.0, 0.4}, {127.0, 1.0}}; + const VelocityCurve base = VelocityCurve::fromPoints(knots, CurveDomain::Unipolar); + for (const double k : {0.75, -0.4, 1.0}) { + std::vector scaled = knots; + for (VelocityPoint& p : scaled) p.value *= k; + const VelocityCurve s = VelocityCurve::fromPoints(scaled, CurveDomain::Bipolar); + for (int v = 0; v <= 127; ++v) CHECK(near(s.eval(v), k * base.eval(v), 1e-12)); + } +} + +static void testBipolarPixelMapPutsZeroOnTheCentreLine() { + // The same box, read in the two domains: value 0 sits at the vertical centre for a bipolar + // curve and at the bottom row for a unipolar one — the one mapping difference the shared + // popup code path has to get right. + const Box box{10, 20, 100, 101}; // 100 value rows: centre is 50 rows down + CHECK(bip().pixelFromPoint(box, {0.0, 0.0}).y == 70); + CHECK(uni().pixelFromPoint(box, {0.0, 0.0}).y == 120); + // Each domain's floor lands on the bottom row, its ceiling on the top. + CHECK(bip().pixelFromPoint(box, {0.0, -1.0}).y == 120); + CHECK(bip().pixelFromPoint(box, {0.0, 1.0}).y == 20); + // And the inverse agrees: the centre row reads back as 0 in bipolar, mid-scale in unipolar. + CHECK(near(bip().pointFromPixel(box, 10, 70).value, 0.0, 1e-12)); + CHECK(near(uni().pointFromPixel(box, 10, 70).value, 0.5, 1e-12)); +} + +static void testBipolarDragCoversTwiceTheValueRange() { + // A drag of N pixels moves twice as much value in bipolar as in unipolar over the same box + // — the domain spans 2.0, not 1.0. Both still land inside their own domain. + const Box box{0, 0, 127, 101}; // 100 value rows + VelocityCurve u = VelocityCurve::flat(); + u.movePoint(0, 0.0, 0.5); + VelocityCurve b = VelocityCurve::zero(); + const VelocityCurve uMoved = VelocityCurve::resolvePointDrag(u, 0, box, 0, -10); + const VelocityCurve bMoved = VelocityCurve::resolvePointDrag(b, 0, box, 0, -10); + CHECK(near(uMoved.points()[0].value, 0.60, 1e-6)); + CHECK(near(bMoved.points()[0].value, 0.20, 1e-6)); } // --- S-VIEW-10 pixel maps (the editor draw/add seam) ----------------------------- @@ -282,29 +383,29 @@ static void testPixelFromPointMapsCornersAndMidpoint() { // (h - 1) rows with amp 1 at the top — assert the drawn corners land where the module's own // hit-test mapping puts them. const Box box{10, 20, 100, 51}; - const auto tl = VelocityCurve::pixelFromPoint(box, {0.0, 1.0}); + const auto tl = uni().pixelFromPoint(box, {0.0, 1.0}); CHECK(tl.x == 10 && tl.y == 20); - const auto br = VelocityCurve::pixelFromPoint(box, {127.0, 0.0}); + const auto br = uni().pixelFromPoint(box, {127.0, 0.0}); CHECK(br.x == 110 && br.y == 70); - const auto mid = VelocityCurve::pixelFromPoint(box, {63.5, 0.5}); + const auto mid = uni().pixelFromPoint(box, {63.5, 0.5}); CHECK(mid.x == 60 && mid.y == 45); // Out-of-box values are clamped by the mapping (velocity 200 draws at the right edge). - const auto clamped = VelocityCurve::pixelFromPoint(box, {200.0, 2.0}); + const auto clamped = uni().pixelFromPoint(box, {200.0, 2.0}); CHECK(clamped.x == 110 && clamped.y == 20); } static void testPointFromPixelInvertsAndClamps() { const Box box{10, 20, 100, 51}; // Exact corners invert exactly. - const VelocityPoint tl = VelocityCurve::pointFromPixel(box, 10, 20); - CHECK(near(tl.velocity, 0.0) && near(tl.amp, 1.0)); - const VelocityPoint br = VelocityCurve::pointFromPixel(box, 110, 70); - CHECK(near(br.velocity, 127.0) && near(br.amp, 0.0)); + const VelocityPoint tl = uni().pointFromPixel(box, 10, 20); + CHECK(near(tl.velocity, 0.0) && near(tl.value, 1.0)); + const VelocityPoint br = uni().pointFromPixel(box, 110, 70); + CHECK(near(br.velocity, 127.0) && near(br.value, 0.0)); // A pixel OUTSIDE the box clamps into the domain (never an invariant-violating point). - const VelocityPoint out = VelocityCurve::pointFromPixel(box, -50, 500); - CHECK(near(out.velocity, 0.0) && near(out.amp, 0.0)); - const VelocityPoint out2 = VelocityCurve::pointFromPixel(box, 500, -50); - CHECK(near(out2.velocity, 127.0) && near(out2.amp, 1.0)); + const VelocityPoint out = uni().pointFromPixel(box, -50, 500); + CHECK(near(out.velocity, 0.0) && near(out.value, 0.0)); + const VelocityPoint out2 = uni().pointFromPixel(box, 500, -50); + CHECK(near(out2.velocity, 127.0) && near(out2.value, 1.0)); } static void testPixelMapsRoundTripWithinOnePixelQuantum() { @@ -315,10 +416,10 @@ static void testPixelMapsRoundTripWithinOnePixelQuantum() { const double ampQuantum = 1.0 / 119.0; const VelocityPoint pts[] = {{0.0, 1.0}, {127.0, 0.0}, {40.0, 0.25}, {90.5, 0.66}, {63.5, 0.5}}; for (const VelocityPoint& p : pts) { - const auto px = VelocityCurve::pixelFromPoint(box, p); - const VelocityPoint back = VelocityCurve::pointFromPixel(box, px.x, px.y); + const auto px = uni().pixelFromPoint(box, p); + const VelocityPoint back = uni().pointFromPixel(box, px.x, px.y); CHECK(std::fabs(back.velocity - p.velocity) <= velQuantum); - CHECK(std::fabs(back.amp - p.amp) <= ampQuantum); + CHECK(std::fabs(back.value - p.value) <= ampQuantum); } } @@ -328,15 +429,15 @@ static void testPixelFromPointAgreesWithPointAtPixel() { VelocityCurve c = VelocityCurve::linear(); const std::size_t idx = c.addPoint(70.0, 0.3); const Box box{0, 0, 200, 100}; - const auto px = VelocityCurve::pixelFromPoint(box, c.points()[idx]); + const auto px = uni().pixelFromPoint(box, c.points()[idx]); CHECK(c.pointAtPixel(box, px.x, px.y) == static_cast(idx)); } static void testPointFromPixelDegenerateBox() { // Zero width -> velocity 0; height <= 1 -> amp 1 (mirrors the forward map's degenerate pins). const Box flat{5, 5, 0, 0}; - const VelocityPoint p = VelocityCurve::pointFromPixel(flat, 50, 50); - CHECK(near(p.velocity, 0.0) && near(p.amp, 1.0)); + const VelocityPoint p = uni().pointFromPixel(flat, 50, 50); + CHECK(near(p.velocity, 0.0) && near(p.value, 1.0)); } static void testFromPointsRoundTripsAValidCurve() { @@ -344,7 +445,7 @@ static void testFromPointsRoundTripsAValidCurve() { orig.addPoint(40.0, 0.2); orig.addPoint(90.0, 0.7); // fromPoints over its OWN points reproduces it exactly (already valid, sort is stable no-op). - const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points()); + const VelocityCurve rebuilt = VelocityCurve::fromPoints(orig.points(), CurveDomain::Unipolar); CHECK(rebuilt.equals(orig)); } @@ -367,6 +468,12 @@ int main() { testResolveDragDegenerateBoxNoMotion(); testFromPointsSortsClampsAndForcesEndpoints(); testFromPointsSubTwoFallsBackToFlat(); + testZeroIsExactlyZeroAtEveryVelocity(); + testBipolarEvalSpansTheNegativeHalf(); + testUnipolarClampsAtZeroWhereBipolarDoesNot(); + testEvalIsHomogeneousInY(); + testBipolarPixelMapPutsZeroOnTheCentreLine(); + testBipolarDragCoversTwiceTheValueRange(); testPixelFromPointMapsCornersAndMidpoint(); testPointFromPixelInvertsAndClamps(); testPixelMapsRoundTripWithinOnePixelQuantum();