Merge Θ-W5-T1: spline EGs — a free-drawn contour alternative to every staged envelope, hard points on the one shared spline, and a deck that redistributes reserved cell width

This commit is contained in:
2026-08-01 00:19:35 -04:00
36 changed files with 2759 additions and 449 deletions
+27 -5
View File
@@ -222,7 +222,28 @@ range-clamped to the same per-param min/max the knobs enforce, so no drag can pr
param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — param a knob couldn't. Two pure modules split the forward (draw) and inverse (edit) maps —
see `envelope_overlay` and `envelope_edit` in Modules below. see `envelope_overlay` and `envelope_edit` in Modules below.
**Which shape an envelope takes is decided by the play mode, not by what it modulates:** **Every envelope is EITHER staged or drawn, and both states persist.** Each of the three
(amp, pitch, filter) carries a `SplineEnv` — a mode plus a contour over NORMALIZED sample time —
beside its staged parameters. Switching modes converts and discards nothing: the inactive state
stays saved but inert, and round-tripping restores the other mode's shape untouched. The
consequences, each with one home:
- **Gate is unavailable while any EG is drawn.** A contour is a pure time function over the full
sample length, which IS the Trigger/one-shot model. `splineActive` (`play_params.h`) is the
predicate; `resolvePlay` enforces it on the way to the engine and the editor's Gate segment
refuses and paints Disabled off the same predicate.
- **A drawn envelope's staged segment knobs go inert** — drawn-but-dead, never removed, never
hidden — including their inner curve dials, which are reached through their outer cell.
`deckKnobInert` (`ui/deck_groups`) is the one place that list lives. The DEPTH knobs (pitch
peak, filter mod amount) stay live: they scale whichever shape is active.
- **Normalized is what makes a contour length-independent.** There are no stored seconds to
rescale, so a different-length capture replays the same shape proportionally.
- **The contours sit on `PlayParams`/`PlaySeconds` directly, not inside the three envelope
structs.** Those are copied whole into the live block, which must stay trivially copyable
(`live_params.h`) — and a contour is not a live control anyway: like the velocity curves it
travels by reload.
**Which shape a STAGED envelope takes is decided by the play mode, not by what it modulates:**
pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes pitch is always AHD; amp and filter are AHDSR in Gate and AHD in Trigger. Both mode shapes
are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the are STORED per envelope, so flipping modes cannot lose either mode's dialled values (the
migration case forces it: an old instance carries both its AHDSR values and its Trigger migration case forces it: an old instance carries both its AHDSR values and its Trigger
@@ -262,13 +283,13 @@ anything for a trigger shape.
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes. - `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read. - `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()`. - `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` — the pure velocity transfer curve shared by all THREE destinations: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. 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 shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `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. - `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/` ### `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`. - `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…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-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning. - `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v13), 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, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag). v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `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. - `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. - `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. - `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -286,8 +307,9 @@ anything for a trigger shape.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search. - `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. - `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. - `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. - `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. A group carries TWO caption-toggle slots, laid right-to-left: the second exists because a group whose knob row is wider than its caption row has caption slack a toggle can occupy for free, and the deck has six pixels of headroom on its first row at the editor's floor width — a `rowToggle` would widen the GROUP and wrap the deck to a fourth row, past what the minimum window holds. **A group's cell run is a RESERVED WIDTH, not a fixed cell size**: a `-1` id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
- `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. - `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`/`overlayEnvEnabled`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled or DRAWN 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.
- `spline_edit` — THE point-editing grammar, and the one place it is written down: left-click grabs a node and adds one in empty space, right-click deletes, control-click toggles hard/smooth. Both spline consumers — the velocity-curve popup and the spline EG overlay — route their mouse-down through `resolveSplineEdit`, so the two cannot drift into two grammars. The endpoint and point-count rules are NOT restated here: `deletePoint` and `addPoint` own them, and the caller applies the resolved action to the curve. Also home to `splineOverlayBox`, the contour's mapping box inside the waveform overlay — the FULL area, no inset, so the drawn contour stays 1:1 with the sample's time axis.
- `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. - `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_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. - `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.
+5
View File
@@ -2,3 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map) add_subdirectory(map)
add_subdirectory(note) add_subdirectory(note)
add_subdirectory(ui) add_subdirectory(ui)
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
# persistence (map), and the point-editing grammar (ui). Declared here because no one
# subdirectory owns the seam it covers.
reasampler_test(spline_egs LINK sampler_core sample_map component_state_io spline_edit deck_groups)
+52
View File
@@ -65,6 +65,20 @@ struct AhdParams {
double decayCurve = util::kCurveNeutral; double decayCurve = util::kCurveNeutral;
}; };
// Which shape an envelope takes: the STAGED knobs, or a free-drawn SPLINE contour. Both states
// are stored side by side and neither converts into the other, so a mode flip is reversible and
// lossless — the inactive one is saved but inert, edited only by switching back to it.
enum class EnvMode { Staged, Spline };
// The free-drawn alternative to a staged envelope: a contour over NORMALIZED sample time,
// covering the full sample length. Normalized is what makes it length-independent — a
// different-length capture replays the same shape proportionally, with no stored seconds to
// rescale. The default is the smooth y = 1 - x downward slope.
struct SplineEnv {
EnvMode mode = EnvMode::Staged;
VelocityCurve contour = VelocityCurve::rampDown();
};
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot: // GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor // note-off-immune, no sustain loop, plays a % of sample length shaped by the AHD. Both honor
// the start point. Default Gate so an instrument with no params set plays as before. // the start point. Default Gate so an instrument with no params set plays as before.
@@ -150,8 +164,46 @@ struct PlayParams {
// baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work. // baseRatio_ at note-on — it is fixed for the note's lifetime, so it costs no per-frame work.
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); VelocityCurve pitchVelocityCurve = VelocityCurve::zero();
FilterParams filter; FilterParams filter;
// The three drawn contours: the alternative to adsr/trigAhd, to pitchEnv.shape, and to
// filter.env/trigEnv respectively. They sit HERE rather than inside the three envelope
// structs because those are copied whole into the live block, which must stay trivially
// copyable (live_params.h) — and a contour is not a live control anyway: like the velocity
// curves it travels by reload.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
}; };
// Whether ANY of the three envelopes is drawn rather than staged. Templated over the two
// parameter representations (frames and the editor's seconds mirror) because both spell the
// three fields identically and the rule must not be written twice — compile-time dispatch,
// no runtime cost, off every hot path.
//
// THE consequence, and its one home: a spline contour is a pure time function over the full
// sample length, which IS the Trigger/one-shot playback model — so Gate is not available while
// any spline EG is active. resolvePlay enforces it on the way to the engine; the editor's
// play-mode toggle refuses the Gate segment so the two agree.
//
// The pitch/filter terms are gated on their own `enabled` flag to match Voice::start's binder
// (voice.cpp only binds pitchSplineCur_/filterSplineCur_ when that flag is set): without this,
// a Spline mode flip on a disabled pitch/filter envelope would cost Gate for zero modulation,
// since the binder would never actually engage. Amp has no such flag, so it counts unconditionally.
template <class Play>
bool splineActive(const Play& p) {
return p.ampSpline.mode == EnvMode::Spline ||
(p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) ||
(p.filter.enabled && p.filterSpline.mode == EnvMode::Spline);
}
// The one enforcement of splineActive's rule (see its doc above). Header-inline and
// allocation-free: play_params.h sits on the per-voice-per-sample include path. Both callers —
// resolvePlay (sample_map.cpp) and the editor's applyControl — route through here, so the two
// cannot drift apart.
template <class Play>
void enforceGateUnavailableWhileDrawn(Play& p) {
if (splineActive(p)) p.playMode = PlayMode::Trigger;
}
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop" // [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
// marker — a held note past the sample end goes silent rather than looping a zero span. // marker — a held note past the sample end goes silent rather than looping a zero span.
struct SampleLoop { struct SampleLoop {
+41 -56
View File
@@ -62,9 +62,18 @@ VelocityCurve VelocityCurve::zero() {
return c; return c;
} }
VelocityCurve VelocityCurve::rampDown() {
VelocityCurve c;
c.points_ = {{kVelMin, kCurveYMax, false}, {kVelMax, 0.0, false}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) { VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDomain domain) {
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for // Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots). // duplicate-X knots).
// Trim before the endpoint synthesis below can add up to two more, then again after, so a
// corrupt over-long blob lands at exactly the ceiling with its two endpoints intact.
if (pts.size() > kMaxCurvePoints) pts.resize(kMaxCurvePoints);
for (VelocityPoint& p : pts) { for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity); p.velocity = clampVelocity(p.velocity);
p.value = clampValue(p.value, domain); p.value = clampValue(p.value, domain);
@@ -77,42 +86,34 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts, CurveDom
return domain == CurveDomain::Bipolar ? zero() : flat(); return domain == CurveDomain::Bipolar ? zero() : flat();
} }
if (pts.front().velocity > kVelMin) { if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value}); pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().value, pts.front().hard});
} else { } else {
pts.front().velocity = kVelMin; pts.front().velocity = kVelMin;
} }
if (pts.back().velocity < kVelMax) { if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().value}); pts.push_back(VelocityPoint{kVelMax, pts.back().value, pts.back().hard});
} else { } else {
pts.back().velocity = kVelMax; pts.back().velocity = kVelMax;
} }
if (pts.size() > kMaxCurvePoints) {
// Drop the interior points nearest the end, never an endpoint.
pts.erase(pts.begin() + static_cast<std::ptrdiff_t>(kMaxCurvePoints) - 1,
pts.end() - 1);
}
VelocityCurve c; VelocityCurve c;
c.domain_ = domain; c.domain_ = domain;
c.points_ = std::move(pts); c.points_ = std::move(pts);
return c; return c;
} }
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 for linear()-style input.
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;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
}
} // namespace
double VelocityCurve::eval(double velocity) const { double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return curveNeutral(domain_); if (points_.empty()) return curveNeutral(domain_);
if (points_.size() == 1) return clampValue(points_[0].value, domain_); if (points_.size() == 1) return clampValue(points_[0].value, domain_);
const double v = clampVelocity(velocity); const double v = clampVelocity(velocity);
if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_); if (v <= points_.front().velocity) return clampValue(points_.front().value, domain_);
if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_); if (v >= points_.back().velocity) return clampValue(points_.back().value, domain_);
// Linear walk: this overload is the COLD one (a note-on, a paint column). The per-sample
// reader is SplineCursor, which shares the same tangent + Hermite functions.
for (std::size_t i = 0; i + 1 < points_.size(); ++i) { for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i]; const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1]; const VelocityPoint& b = points_[i + 1];
@@ -120,55 +121,38 @@ double VelocityCurve::eval(double velocity) const {
const double span = b.velocity - a.velocity; const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): zero-width segment, no interior to blend. // Coincident-X neighbours (a step): zero-width segment, no interior to blend.
if (span <= 0.0) return clampValue(b.value, domain_); if (span <= 0.0) return clampValue(b.value, domain_);
// 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.value - a.value) / span; const double d = (b.value - a.value) / span;
const SegmentTangents m = segmentTangents(points_.data(), points_.size(), i, d, span);
double mA = d; const double y = hermiteAt(a.value, b.value, span, m.mA, m.mB,
if (i > 0) { (v - a.velocity) / span);
const VelocityPoint& prev = points_[i - 1];
const double spanPrev = a.velocity - prev.velocity;
if (spanPrev > 0.0) {
const double dPrev = (a.value - prev.value) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0;
}
}
double mB = d;
if (i + 2 < points_.size()) {
const VelocityPoint& next = points_[i + 2];
const double spanNext = next.velocity - b.velocity;
if (spanNext > 0.0) {
const double dNext = (next.value - b.value) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0;
}
}
const double t = (v - a.velocity) / span;
const double t2 = t * t;
const double t3 = t2 * t;
const double h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
const double h10 = t3 - 2.0 * t2 + t;
const double h01 = -2.0 * t3 + 3.0 * t2;
const double h11 = t3 - t2;
const double y = h00 * a.value + h10 * span * mA + h01 * b.value + h11 * span * mB;
return clampValue(y, domain_); return clampValue(y, domain_);
} }
} }
return clampValue(points_.back().value, domain_); // 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 value) { int VelocityCurve::addPoint(double velocity, double value) {
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_)}; // At the ceiling the add is REFUSED outright rather than trading a point away — the existing
// contour must come through an over-add bit-identical.
if (points_.size() >= kMaxCurvePoints) return -1;
const VelocityPoint p{clampVelocity(velocity), clampValue(value, domain_), false};
// First index strictly greater, so a duplicate-X point lands immediately after the existing one. // First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0; std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p); points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
return i; return static_cast<int>(i);
}
bool VelocityCurve::toggleHard(std::size_t index) {
if (index >= points_.size()) return false;
points_[index].hard = !points_[index].hard;
return true;
}
bool VelocityCurve::setHard(std::size_t index, bool hard) {
if (index >= points_.size()) return false;
points_[index].hard = hard;
return true;
} }
VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) { VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, double value) {
@@ -187,7 +171,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
const double hi = points_[index + 1].velocity; const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi); newVel = std::clamp(clampVelocity(velocity), lo, hi);
} }
points_[index] = VelocityPoint{newVel, newValue}; points_[index] = VelocityPoint{newVel, newValue, points_[index].hard};
return points_[index]; return points_[index];
} }
@@ -255,6 +239,7 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
for (std::size_t i = 0; i < points_.size(); ++i) { 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].velocity - other.points_[i].velocity) > eps) return false;
if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false; if (std::fabs(points_[i].value - other.points_[i].value) > eps) return false;
if (points_[i].hard != other.points_[i].hard) return false;
} }
return true; return true;
} }
+182 -17
View File
@@ -1,21 +1,32 @@
// velocity_curve.h — the velocity->modulation transfer curve, shared by all three // velocity_curve.h — THE monotone spline, shared by every consumer: the three velocity
// destinations (amp gain, pitch offset, filter cutoff offset). eval(velocity) is called once // transfer curves (amp gain, pitch offset, filter cutoff offset), evaluated once per note-on,
// per note-on in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit // and the spline EGs, evaluated per voice per sample through SplineCursor. Editor
// pixel Box rather than a Rect: this module sits below sampler_core in the link graph and must // hit-test/inverse-map take an explicit pixel Box rather than a Rect: this module sits below
// not gain a transitive dependency on editor-layout types. // sampler_core in the link graph and must not gain a dependency on editor-layout types.
#pragma once #pragma once
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] — the X span every point clamps into. // The curve's canonical X span. For the three velocity consumers it IS the MIDI velocity
inline constexpr double kVelMin = 0.0; // domain; a spline EG maps normalized sample time onto the same span, which is what lets one
inline constexpr double kVelMax = 127.0; // implementation serve both without a second X domain to keep in sync.
inline constexpr double kCurveXMin = 0.0;
inline constexpr double kCurveXMax = 127.0;
inline constexpr double kVelMin = kCurveXMin; // the velocity consumers' spelling of the span
inline constexpr double kVelMax = kCurveXMax;
inline constexpr double kCurveYMax = 1.0; inline constexpr double kCurveYMax = 1.0;
// Point-count ceiling. A MUSICAL bound, not a performance one: long rhythmic phrases need the
// resolution, and at roughly two points per articulation event 128 is about four bars of 16ths.
// Segment lookup is logarithmic (<=7 steps at this ceiling), so there is no performance case for
// lowering it. DO NOT LOWER.
inline constexpr std::size_t kMaxCurvePoints = 128;
// The curve's Y range. UNIPOLAR [0,1] is a GAIN — the amp's domain, where the do-nothing // 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 shape — the pitch and filter // curve is flat at 1. BIPOLAR [-1,1] is a SIGNED modulation shape — the pitch and filter
// domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A // domains, where the do-nothing curve is flat at 0 and the sign picks the direction. A
@@ -34,19 +45,75 @@ constexpr double curveNeutral(CurveDomain d) { return d == CurveDomain::Bipolar
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves // 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. // through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint { struct VelocityPoint {
double velocity = 0.0; // X, [0,127] double velocity = 0.0; // X, over the canonical span
double value = 0.0; // Y, in the owning curve's domain double value = 0.0; // Y, in the owning curve's domain
// A HARD point does no smoothing on either side: it terminates the monotone sub-curve, so
// the two adjacent segments meet at their own natural angle instead of a shared derivative.
// Points are smooth by default; see segmentTangents for the mechanism.
bool hard = false;
}; };
// 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 for linear()-style input.
inline 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;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
}
struct SegmentTangents {
double mA = 0.0;
double mB = 0.0;
};
// The Hermite tangents for segment [i, i+1] of an X-ordered point array, where `d` is that
// segment's secant slope and `span` its X width (> 0).
//
// A HARD point is treated exactly as the array's own end is: the tangent there is the segment's
// own secant, so smoothing stops at it. That single rule is the whole hard-point enhancement —
// the contour becomes one or more monotone splines joined at their natural angles, and each
// sub-curve keeps Fritsch-Carlson's no-overshoot guarantee because m == d satisfies its bound.
inline SegmentTangents segmentTangents(const VelocityPoint* p, std::size_t n, std::size_t i,
double d, double span) {
SegmentTangents t{d, d};
if (i > 0 && !p[i].hard) {
const double spanPrev = p[i].velocity - p[i - 1].velocity;
t.mA = (spanPrev > 0.0)
? fritschCarlsonTangent((p[i].value - p[i - 1].value) / spanPrev, d, spanPrev,
span)
: 0.0;
}
if (i + 2 < n && !p[i + 1].hard) {
const double spanNext = p[i + 2].velocity - p[i + 1].velocity;
t.mB = (spanNext > 0.0)
? fritschCarlsonTangent(d, (p[i + 2].value - p[i + 1].value) / spanNext, span,
spanNext)
: 0.0;
}
return t;
}
// The cubic Hermite basis evaluated at t in [0,1] across a segment of width `span`.
inline double hermiteAt(double y0, double y1, double span, double mA, double mB, double t) {
const double t2 = t * t;
const double t3 = t2 * t;
return (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * span * mA +
(-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * span * mB;
}
// Pick radius (px) around a node's drawn point for the editor hit-test. // Pick radius (px) around a node's drawn point for the editor hit-test.
inline constexpr int kCurveNodeGrabRadius = 6; inline constexpr int kCurveNodeGrabRadius = 6;
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite // An X-ordered list of control points spanning the canonical X span, evaluated as ONE OR MORE
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never // monotone cubic Hermite splines (Fritsch-Carlson slope limiting) joined at the hard points — a
// overshoots a segment's value range. For collinear knots the tangents reduce to the secant // genuine curve, not a polyline, that provably never overshoots any segment's value range. The
// slope, so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints // guarantee is PER SEGMENT, so a contour is free to rise and fall. For collinear knots the
// (velocity 0 and 127) are load-bearing: they keep eval total over the domain and are never // tangents reduce to the secant slope, so the spline reproduces linear()'s straight line to
// deletable. // within ~1e-15. The two endpoints are load-bearing: they keep eval total over the domain and
// are never deletable.
class VelocityCurve { class VelocityCurve {
public: public:
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see // flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the unipolar default — see
@@ -56,6 +123,10 @@ public:
static VelocityCurve linear(); static VelocityCurve linear();
// The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn. // The bipolar default: flat at 0, so velocity modulates nothing until a curve is drawn.
static VelocityCurve zero(); static VelocityCurve zero();
// y = 1 - x: the smooth downward slope a freshly created spline EG opens on. Two collinear
// knots, so it is straight — and straight is smooth. NOT a change to any velocity curve's
// own default.
static VelocityCurve rampDown();
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps // Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// each point into `domain`, stable-sorts by velocity, forces both endpoints present // each point into `domain`, stable-sorts by velocity, forces both endpoints present
@@ -73,8 +144,15 @@ public:
double eval(double velocity) const; double eval(double velocity) const;
// Inserted at a velocity duplicating an existing point lands immediately after it, so a // Inserted at a velocity duplicating an existing point lands immediately after it, so a
// subsequent move can separate them. Returns the inserted index. // subsequent move can separate them. Returns the inserted index, or -1 when the curve is
std::size_t addPoint(double velocity, double value); // already at kMaxCurvePoints — a refusal leaves the contour bit-identical.
int addPoint(double velocity, double value);
// Flips a point between hard and smooth. Out-of-range index is a no-op returning false.
// Permitted on the endpoints, where it changes nothing evaluable: an endpoint's outward
// tangent is already its own secant, which is what hard means.
bool toggleHard(std::size_t index);
bool setHard(std::size_t index, bool hard);
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two // Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// endpoints are pinned in X (only their value moves); out-of-range index is a no-op. // endpoints are pinned in X (only their value moves); out-of-range index is a no-op.
@@ -130,4 +208,91 @@ private:
CurveDomain domain_ = CurveDomain::Unipolar; CurveDomain domain_ = CurveDomain::Unipolar;
}; };
// The RT read head over a contour: an indexed segment search plus one Hermite evaluation, with
// the segment and its two tangents cached across samples so a monotone read costs one compare.
// Header-inline, branch-only, NO allocation and NO virtual dispatch — it runs per voice per
// sample. A jump (a loop wrap, a fresh note) falls back to a binary search, <= 7 steps at the
// 128-point ceiling.
//
// Holds a RAW POINTER into the bound curve's point array: the caller guarantees the curve
// outlives the cursor. The voice binds against its SampleData, which has exactly that lifetime.
class SplineCursor {
public:
// Binds `c` if it has an evaluable segment; a shorter curve leaves the cursor inactive so
// the caller's `if (active())` skips the whole spline path.
void bind(const VelocityCurve& c) {
const std::vector<VelocityPoint>& pts = c.points();
if (pts.size() < 2) { clear(); return; }
pts_ = pts.data();
n_ = pts.size();
select(0);
}
void clear() { pts_ = nullptr; n_ = 0; }
bool active() const { return n_ >= 2; }
// True once the cursor has settled on the contour's LAST segment. On its own this does NOT
// make a 0 read here a terminus: the final segment's LEFT endpoint can also be 0 (a 2-point
// contour is nothing but a single "final" segment starting at frame 0), which would read 0
// while about to rise. Voice::tickAmplitude pairs this with segmentEndValue() == 0 — the
// segment's RIGHT endpoint, i.e. the whole contour's true end — before calling a 0 read the
// note's genuine permanent terminus.
bool onFinalSegment() const { return seg_ + 2 == n_; }
// The CURRENT SEGMENT's right endpoint — not a contour-level concept despite the name's
// shape; it is the whole contour's terminal Y only when paired with onFinalSegment() (see
// there). Named for what it returns, not for its one call site's use of it.
double segmentEndValue() const { return y1_; }
// `phase` is normalized position over the contour's whole span, [0,1]; out-of-range clamps
// to the terminal values (a note past its span holds the contour's last level).
double eval(double phase) {
const double x = (phase <= 0.0) ? kCurveXMin
: (phase >= 1.0) ? kCurveXMax
: kCurveXMin + phase * (kCurveXMax - kCurveXMin);
if (x <= x0_ && seg_ == 0) return y0_;
if (x >= x1_ && seg_ + 2 == n_) return y1_;
// x <= x0_ (not just <): landing exactly on the cached segment's LEFT edge normally
// reproduces y0_ either way, but at a duplicate-X step (coincident knots with
// DIFFERENT Y) the cached segment may be the LATER of the two — re-locate so a query
// sitting exactly on the shared X always resolves through locate()'s tie-break, which
// agrees with the cold VelocityCurve::eval's first-containing-segment rule.
if (x <= x0_ || x > x1_) locate(x);
if (span_ <= 0.0) return y1_; // coincident-X knots: a step, no interior to blend
return hermiteAt(y0_, y1_, span_, mA_, mB_, (x - x0_) / span_);
}
private:
// The common case is the next segment (a monotone read walking forward); anything else is a
// binary search over the X-ordered array.
void locate(double x) {
if (x > x1_ && seg_ + 2 < n_ && x <= pts_[seg_ + 2].velocity) { select(seg_ + 1); return; }
// Leftmost segment containing x: smallest lo with pts_[lo+1].velocity >= x. At
// coincident-X knots (a drawn step) this picks the FIRST segment ending at the shared X,
// matching VelocityCurve::eval's cold linear walk — the two readers must agree here or a
// backwards/jumping read can return a different knot's Y than a forward one would.
std::size_t lo = 0, hi = n_ - 2;
while (lo < hi) {
const std::size_t mid = lo + (hi - lo) / 2;
if (pts_[mid + 1].velocity < x) lo = mid + 1; else hi = mid;
}
select(lo);
}
void select(std::size_t i) {
seg_ = i;
x0_ = pts_[i].velocity;
x1_ = pts_[i + 1].velocity;
y0_ = pts_[i].value;
y1_ = pts_[i + 1].value;
span_ = x1_ - x0_;
const SegmentTangents t =
segmentTangents(pts_, n_, i, span_ > 0.0 ? (y1_ - y0_) / span_ : 0.0, span_);
mA_ = t.mA;
mB_ = t.mB;
}
const VelocityPoint* pts_ = nullptr;
std::size_t n_ = 0;
std::size_t seg_ = 0;
double x0_ = 0.0, x1_ = 0.0, y0_ = 0.0, y1_ = 0.0, span_ = 0.0, mA_ = 0.0, mB_ = 0.0;
};
} // namespace reasampler::instrument::engine } // namespace reasampler::instrument::engine
+32 -2
View File
@@ -72,6 +72,26 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames, loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
frameCount, playMode_ == PlayMode::Gate); frameCount, playMode_ == PlayMode::Gate);
// Bind whichever EGs are drawn. Rebound on EVERY note-on rather than cached: a reload hands
// the engine a fresh SampleData, so a stale pointer into the previous one is the bug this
// avoids. A Staged EG clears its cursor, which is what keeps the per-sample path off the
// spline branch entirely.
splineScale_ = frameCount > 0 ? 1.0 / static_cast<double>(frameCount) : 0.0;
if (p.ampSpline.mode == EnvMode::Spline) ampSplineCur_.bind(p.ampSpline.contour);
else ampSplineCur_.clear();
if (p.pitchEnv.enabled && p.pitchSpline.mode == EnvMode::Spline) {
pitchSplineCur_.bind(p.pitchSpline.contour);
pitchSplineDepth_ = p.pitchEnv.peakSemitones;
} else {
pitchSplineCur_.clear();
pitchSplineDepth_ = 0.0;
}
if (p.filter.enabled && p.filterSpline.mode == EnvMode::Spline) {
filterSplineCur_.bind(p.filterSpline.contour);
} else {
filterSplineCur_.clear();
}
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to // Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span. // frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount) const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
@@ -82,8 +102,12 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
playEnd_ = 0; // unused in Gate playEnd_ = 0; // unused in Gate
} else { } else {
// Trigger: play [start, playEnd) where // Trigger: play [start, playEnd) where
// playEnd = start + round(lengthFraction*(frames-start)). // playEnd = start + round(lengthFraction*(frames-start)) — except kTrigLength is INERT
double frac = p.trigger.lengthFraction; // while any spline EG is active (splineActive, play_params.h): a contour is a pure
// function over the FULL sample length, so truncating playEnd_ to a %-length would
// hard-cut it mid-shape. The staged Trigger AHD below (trigSpan) plays the same full
// span in that case, matching the "drawn-but-dead" treatment of the other staged knobs.
double frac = splineActive(p) ? 1.0 : p.trigger.lengthFraction;
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately) if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
if (frac > 1.0) frac = 1.0; if (frac > 1.0) frac = 1.0;
std::int64_t playLen = static_cast<std::int64_t>( std::int64_t playLen = static_cast<std::int64_t>(
@@ -238,6 +262,12 @@ void Voice::applyLive(const instrument::engine::LiveValues& live, bool snap) {
else ampAhd_.applyLive(sourceOffset(), live.ampAhd); else ampAhd_.applyLive(sourceOffset(), live.ampAhd);
pitchEnv_.applyLive(live.pitchEnv); pitchEnv_.applyLive(live.pitchEnv);
} }
// The pitch DEPTH knob stays live under a spline (core/instrument/CLAUDE.md), but
// pitchSplineDepth_ is a plain member latched at note-on — unlike filter's modAmount_,
// which already glides through rModAmount_'s live ramp regardless of spline state (below),
// this is the one place a live pitch-depth move must be re-applied by hand. Only meaningful
// while pitchSplineCur_ is bound; harmless (and cheap) to set otherwise.
pitchSplineDepth_ = live.pitchEnv.peakSemitones;
if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload if (!filterOn_) return; // filter enable is a discrete toggle: it travels by reload
if (snap) { if (snap) {
+41 -6
View File
@@ -25,6 +25,7 @@ namespace reasampler {
using audio::AudioSample; using audio::AudioSample;
using instrument::engine::PitchShifter; using instrument::engine::PitchShifter;
using instrument::engine::SplineCursor;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop; using instrument::engine::loop::ResolvedLoop;
@@ -164,14 +165,33 @@ public:
} }
private: private:
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per // The read head as a fraction of the whole sample — the domain every spline EG is a pure
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD // function of. Zero-length sample leaves splineScale_ at 0, which parks every contour on
// its opening value.
double splinePhase() const { return readPos_ * splineScale_; }
// This frame's amplitude in [0,1] from the active envelope. Spline: the drawn contour read
// at the normalized position (one cached-segment compare per frame). Gate: AHDSR ticks once
// per output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source // is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
// frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees // frames regardless of pitch engine. Sets amplitudeDone_ on finish so advanceFrame frees
// the voice. // the voice.
double tickAmplitude() { double tickAmplitude() {
double amp; double amp;
if (playMode_ == PlayMode::Gate) { // playMode_ is Trigger whenever a spline is genuinely reachable (resolvePlay forces it —
// splineActive, play_params.h); the guard is a pure-core defense against a hand-built
// SampleData pairing Gate with an amp spline, which would otherwise bypass env_
// entirely — release() then has no envelope to end, and an active sustain loop rings
// forever.
if (ampSplineCur_.active() && playMode_ == PlayMode::Trigger) {
// Early-free at a genuine permanent terminus (the spline analogue of a staged AHD's
// finished()) — onFinalSegment()/segmentEndValue()'s own doc comments own the why.
amp = ampSplineCur_.eval(splinePhase());
if (amp == 0.0 && ampSplineCur_.onFinalSegment() &&
ampSplineCur_.segmentEndValue() == 0.0) {
amplitudeDone_ = true;
}
} else if (playMode_ == PlayMode::Gate) {
amp = env_.tick(); amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true; if (env_.finished()) amplitudeDone_ = true;
} else { } else {
@@ -202,9 +222,11 @@ private:
// The filter envelope takes the amp's shape under the active mode — AHDSR in Gate, // The filter envelope takes the amp's shape under the active mode — AHDSR in Gate,
// the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the // the source-offset AHD in Trigger. playMode_ is fixed for the note's lifetime, so the
// branch is perfectly predicted. // branch is perfectly predicted.
const double envOut = (playMode_ == PlayMode::Gate) const double envOut = filterSplineCur_.active()
? filterSplineCur_.eval(splinePhase())
: ((playMode_ == PlayMode::Gate)
? filterEnv_.tick() ? filterEnv_.tick()
: filterAhd_.amplitudeAt(sourceOffset()); : filterAhd_.amplitudeAt(sourceOffset()));
double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut; double cut = static_cast<double>(filterBaseCutoff_) + filterModAmount_ * envOut;
if (cut < 0.0) cut = 0.0; if (cut < 0.0) cut = 0.0;
if (cut > 1.0) cut = 1.0; if (cut > 1.0) cut = 1.0;
@@ -394,7 +416,9 @@ private:
seedTerminalDeclick(); seedTerminalDeclick();
} }
const double gain = amp * velocityGain_; const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick(); const double pitchEnvSemis = pitchSplineCur_.active()
? pitchSplineDepth_ * pitchSplineCur_.eval(splinePhase())
: pitchEnv_.tick();
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
// pow entirely — no per-frame transcendental on the common path. // pow entirely — no per-frame transcendental on the common path.
@@ -585,6 +609,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The three drawn contours, bound at note-on to the loaded capture's own point arrays (the
// SampleData outlives the voice — same contract as sample_). A Staged EG leaves its cursor
// inactive, so a purely staged instrument's per-sample path gains three predicted branches
// and nothing else. splineScale_ is 1/frameCount, the readPos -> [0,1] map every contour
// shares; pitchSplineDepth_ is the pitch envelope's peak, zero while it is disabled.
SplineCursor ampSplineCur_;
SplineCursor pitchSplineCur_;
SplineCursor filterSplineCur_;
double splineScale_ = 0.0;
double pitchSplineDepth_ = 0.0;
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span // The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing. // are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime. // Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
+22 -2
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine. // 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 // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v12) must be preserved exactly. This header is the ONE home for both ladders // payload v1..v13) 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. // and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint> #include <cstdint>
@@ -104,6 +104,22 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve // which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0. // drawn into the negative half comes back with that half clamped to 0.
// //
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
// x + 8-byte LE y (doubles) + 1 byte hard. x spans the curve's canonical [0,127] (a
// normalized-time contour maps onto that same span — velocity_curve.h owns why one span
// serves both), y is UNIPOLAR [0,1]; the pitch and filter depth knobs scale it.
// (b) the HARD-FLAG tails for the three v7/v9/v12 velocity curves — amp, filter, pitch, in
// that order. Each: 4-byte LE count N, then N bytes. Those three curve blocks are FROZEN
// at 16 bytes/point and cannot grow a per-point flag, so the flags ride here instead. A
// tail whose count does not match the curve as read is IGNORED (the curve keeps its
// flags-off default) rather than applied to the wrong knots — a repaired blob loses the
// hard points, never misplaces them.
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in // 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 // shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to // them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -135,7 +151,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel // 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 // 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. // a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 12; // v11 + the velocity->pitch curve inline constexpr std::uint32_t kParamsPayloadVersion = 13; // v12 + the dual Staged/Spline state
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u; inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and // The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -159,6 +175,10 @@ inline constexpr std::uint32_t kParamsLoopVersion = 11;
// pre-v12 curve's y values are already valid bipolar ones. // pre-v12 curve's y values are already valid bipolar ones.
inline constexpr std::uint32_t kParamsVelocityVersion = 12; inline constexpr std::uint32_t kParamsVelocityVersion = 12;
// v12 + the dual Staged/Spline state; the appended tail branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsSplineVersion = 13;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to // (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 // 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 // (frames / projectRate = seconds) — the same rate the build already receives, so the
+96 -1
View File
@@ -50,6 +50,27 @@ void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
} }
} }
// A spline EG: 1 byte mode, then the contour as count + (x, y, hard) per point. Distinct from
// putCurve because the three velocity-curve blocks are frozen at 16 bytes/point and cannot grow
// the hard flag; this block was born with it.
void putSplineEnv(std::vector<std::uint8_t>& out, const SplineEnv& s) {
out.push_back(s.mode == EnvMode::Spline ? 1 : 0);
const std::vector<VelocityPoint>& pts = s.contour.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.value));
out.push_back(pt.hard ? 1 : 0);
}
}
// The hard flags of an already-written velocity curve: count + one byte per point.
void putHardFlags(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
const std::vector<VelocityPoint>& pts = curve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) out.push_back(pt.hard ? 1 : 0);
}
// A stored AHD's five doubles, in one order shared by every AHD on the wire. // A stored AHD's five doubles, in one order shared by every AHD on the wire.
void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) { void putAhd(std::vector<std::uint8_t>& out, const AhdSeconds& a) {
putLE(out, doubleToBits(a.attackSeconds)); putLE(out, doubleToBits(a.attackSeconds));
@@ -107,13 +128,71 @@ void readCurveTail(ByteReader& r, VelocityCurve& curve,
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) { for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64()); const double vel = bitsToDouble(r.u64());
const double value = bitsToDouble(r.u64()); const double value = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, value}); // A NaN velocity breaks fromPoints' stable_sort (not a strict weak ordering with NaN
// present); a NaN value reaches the RT eval's multiply. Same non-finite-falls-back-to-0
// guard as every other wire double this codec reads.
pts.push_back(VelocityPoint{std::isfinite(vel) ? vel : 0.0,
std::isfinite(value) ? value : 0.0});
} }
if (r.ok) { if (r.ok) {
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain); curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts), domain);
} }
} }
// Read a spline EG. A truncated read leaves `s` at its Staged/default-contour construction
// value, which is what makes a pre-v13 blob play exactly as it did.
void readSplineEnv(ByteReader& r, SplineEnv& s) {
const bool spline = (r.u8() != 0);
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (17 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 17));
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
const double x = bitsToDouble(r.u64());
const double y = bitsToDouble(r.u64());
const bool hard = (r.u8() != 0);
// Same NaN guard as readCurveTail: an x NaN breaks fromPoints' sort, a y NaN reaches
// SplineCursor::eval's multiply into the per-sample amp gain.
pts.push_back(VelocityPoint{std::isfinite(x) ? x : 0.0, std::isfinite(y) ? y : 0.0, hard});
}
if (!r.ok) return;
s.mode = spline ? EnvMode::Spline : EnvMode::Staged;
if (pts.size() < 2) {
// fromPoints' own sub-2-point fallback is flat()/zero() by DOMAIN — the neutral velocity
// curve response (a full-open gate). A spline EG's documented neutral is y = 1 - x
// instead, so a malformed/short block substitutes that rather than fromPoints' default.
s.contour = VelocityCurve::rampDown();
return;
}
s.contour = VelocityCurve::fromPoints(std::move(pts),
reasampler::instrument::engine::CurveDomain::Unipolar);
}
// Apply a hard-flag tail to an already-read velocity curve. A count that disagrees with the
// curve fromPoints actually produced — including an out-of-bounds or truncated one — is
// dropped rather than applied to shifted knots, and the whole params record parsed ahead of
// this tail survives (component_state_io.h's documented promise): if THIS call is what tripped
// r.ok (a truncated count field), it is revived before returning. An r.ok already false on
// entry (an earlier, unrelated field genuinely truncated) is left alone — that failure is not
// this tail's to forgive.
void readHardFlags(ByteReader& r, VelocityCurve& curve) {
const bool enteredOk = r.ok;
const std::uint32_t count = r.u32();
if (!r.ok) {
if (enteredOk) r.ok = true; // a truncated count field: nothing to apply
return;
}
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
if (count > remaining) return; // bound-and-skip: cannot safely reserve/read this many
std::vector<std::uint8_t> flags;
flags.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) flags.push_back(r.u8());
if (flags.size() != curve.size()) return;
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default, // 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. The curve reads as // which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its // bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
@@ -331,6 +410,14 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, asU64(p.loopCrossfadeFrames)); putLE(out, asU64(p.loopCrossfadeFrames));
// v12: the velocity->pitch curve. // v12: the velocity->pitch curve.
putCurve(out, pp.pitchVelocityCurve); putCurve(out, pp.pitchVelocityCurve);
// v13: the dual Staged/Spline state — the three contours, then the hard flags the three
// frozen velocity-curve blocks above had no room for.
putSplineEnv(out, pp.ampSpline);
putSplineEnv(out, pp.pitchSpline);
putSplineEnv(out, pp.filterSpline);
putHardFlags(out, p.velocityCurve);
putHardFlags(out, f.velocityCurve);
putHardFlags(out, pp.pitchVelocityCurve);
} }
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by // Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -373,6 +460,14 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readCurveTail(r, p.play.pitchVelocityCurve, readCurveTail(r, p.play.pitchVelocityCurve,
reasampler::instrument::engine::CurveDomain::Bipolar); reasampler::instrument::engine::CurveDomain::Bipolar);
} }
if (pv >= kParamsSplineVersion) {
readSplineEnv(r, p.play.ampSpline);
readSplineEnv(r, p.play.pitchSpline);
readSplineEnv(r, p.play.filterSpline);
readHardFlags(r, p.velocityCurve);
readHardFlags(r, p.play.filter.velocityCurve);
readHardFlags(r, p.play.pitchVelocityCurve);
}
// A truncated record leaves whatever parsed plus construction defaults for the rest — // A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had. // the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{}; if (!r.ok) return PayloadRead{};
+10
View File
@@ -256,6 +256,16 @@ PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
out.filter.env.decayCurve = stored.filter.env.decayCurve; out.filter.env.decayCurve = stored.filter.env.decayCurve;
out.filter.env.releaseCurve = stored.filter.env.releaseCurve; out.filter.env.releaseCurve = stored.filter.env.releaseCurve;
out.filter.trigEnv = resolveAhd(stored.filter.trigEnv); out.filter.trigEnv = resolveAhd(stored.filter.trigEnv);
// The three drawn contours are normalized over the sample's own length, so no rate resolves
// them — they carry through verbatim, which is also what makes a different-length capture
// replay the same shape proportionally.
out.ampSpline = stored.ampSpline;
out.pitchSpline = stored.pitchSpline;
out.filterSpline = stored.filterSpline;
// Every field splineActive reads on `out` is already copied from `stored` above, so this
// enforces the same rule enforceGateUnavailableWhileDrawn's doc comment (play_params.h)
// describes — the editor's applyControl is the other caller, so the two cannot drift.
enforceGateUnavailableWhileDrawn(out);
return out; return out;
} }
+6
View File
@@ -202,6 +202,12 @@ struct PlaySeconds {
PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default
VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default
FilterSeconds filter; // per-voice filter, off by default FilterSeconds filter; // per-voice filter, off by default
// The three drawn contours, in the same slots the engine bundle carries them (play_params.h
// owns why they sit beside the envelopes rather than inside them). Normalized over the
// sample's own length, so resolvePlay needs no rate for them.
SplineEnv ampSpline;
SplineEnv pitchSpline;
SplineEnv filterSpline;
}; };
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live // Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
+12
View File
@@ -56,6 +56,18 @@ reasampler_pure_library(deck_groups
# needs the band allocator deck_groups itself has no reason to depend on. # needs the band allocator deck_groups itself has no reason to depend on.
reasampler_test(deck_groups LINK deck_groups sample_bands) reasampler_test(deck_groups LINK deck_groups sample_bands)
# The point-editing grammar both spline consumers share, so it links the curve itself (unlike
# envelope_overlay/envelope_edit, which stay engine-free the staged envelopes touch no curve).
reasampler_pure_library(spline_edit
SOURCES spline_edit.cpp
LINK PUBLIC editor_geometry velocity_curve)
# waveform_view and sample_bands are linked for the test only: resolveWaveformClaim's
# smallest-target-first tests build the real node/tab/marker geometry
# editor_input_waveform.cpp's mouseDownWaveform composes (the shell that calls it has no test
# target of its own), which needs waveform_view's marker/tab primitives and sample_bands'
# kWaveformMinHeight floor.
reasampler_test(spline_edit LINK spline_edit waveform_view sample_bands)
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry) reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
# velocity_curve is linked for the test only: the sheet's geometry is domain-agnostic, and # 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. # proving that takes a curve of each domain mapped through the one curveBox.
+70 -16
View File
@@ -9,6 +9,11 @@ namespace reasampler::instrument::ui {
namespace { namespace {
int id(DeckParam p) { return static_cast<int>(p); } int id(DeckParam p) { return static_cast<int>(p); }
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); } double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
// Segment width of the three Staged|Spline toggles. Sized so each env group's caption row stays
// no wider than its knob row — the ceiling is PITCH ENV's, whose caption row lands exactly on
// its four-cell knob row at 23. Raising it reflows the deck's first row.
constexpr int kEnvModeSegW = 23;
} // namespace } // namespace
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; } double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
@@ -31,6 +36,9 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
penv.captionWidth = 58; penv.captionWidth = 58;
penv.captionRadio = {id(DeckParam::kPitchEnvSelect)}; penv.captionRadio = {id(DeckParam::kPitchEnvSelect)};
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32}; penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
// The mode toggle rides the caption slack rather than the knob row — costs no group
// width; see this module's CLAUDE.md bullet (knob_deck) for the headroom this relies on.
penv.captionToggle2 = {id(DeckParam::kPitchEnvMode), kEnvModeSegW};
penv.cellIds = {id(DeckParam::kPitchEnvAttack), penv.cellIds = {id(DeckParam::kPitchEnvAttack),
id(DeckParam::kPitchEnvHold), id(DeckParam::kPitchEnvHold),
id(DeckParam::kPitchEnvDecay), id(DeckParam::kPitchEnvDecay),
@@ -58,6 +66,7 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
fenv.id = kGroupFilterEnv; fenv.id = kGroupFilterEnv;
fenv.captionWidth = 66; fenv.captionWidth = 66;
fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)}; fenv.captionRadio = {id(DeckParam::kFilterEnvSelect)};
fenv.captionToggle2 = {id(DeckParam::kFilterEnvMode), kEnvModeSegW};
if (trigger) { if (trigger) {
fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold), fenv.cellIds = {id(DeckParam::kFilterTrigAttack), id(DeckParam::kFilterTrigHold),
id(DeckParam::kFilterTrigDecay), -1, -1}; id(DeckParam::kFilterTrigDecay), -1, -1};
@@ -76,10 +85,12 @@ std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
amp.captionWidth = 78; amp.captionWidth = 78;
amp.captionRadio = {id(DeckParam::kAmpEnvSelect)}; amp.captionRadio = {id(DeckParam::kAmpEnvSelect)};
amp.captionToggle = {id(DeckParam::kPlayMode), 44}; amp.captionToggle = {id(DeckParam::kPlayMode), 44};
amp.captionToggle2 = {id(DeckParam::kAmpEnvMode), kEnvModeSegW};
if (trigger) { if (trigger) {
// The play span first, then the AHD that shapes it, time-ordered left-to-right so // The play span first, then the AHD that shapes it, time-ordered left-to-right so
// the row reads like the drawn envelope. One blank keeps the group's width — and // the row reads like the drawn envelope. One reserve (-1) keeps the group's width —
// therefore its neighbours' placement — identical across a mode flip. // and therefore its neighbours' placement — identical across a mode flip; its
// pixels go to the four cells that remain (knob_deck.h).
amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack), amp.cellIds = {id(DeckParam::kTrigLength), id(DeckParam::kTrigAttack),
id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1}; id(DeckParam::kTrigHold), id(DeckParam::kTrigDecay), -1};
} else { } else {
@@ -204,6 +215,11 @@ bool isLiveDeckParam(DeckParam id) {
case DeckParam::kAmpEnvSelect: case DeckParam::kAmpEnvSelect:
case DeckParam::kPitchEnvSelect: case DeckParam::kPitchEnvSelect:
case DeckParam::kFilterEnvSelect: case DeckParam::kFilterEnvSelect:
// A mode toggle names a different envelope, not a different setting of one — the same
// reason every other discrete toggle above is excluded.
case DeckParam::kAmpEnvMode:
case DeckParam::kPitchEnvMode:
case DeckParam::kFilterEnvMode:
case DeckParam::kVoiceCount: case DeckParam::kVoiceCount:
case DeckParam::kVoiceMode: case DeckParam::kVoiceMode:
case DeckParam::kMonoTrigger: case DeckParam::kMonoTrigger:
@@ -229,24 +245,70 @@ OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId) {
return (current == picked) ? OverlayEnv::kNone : picked; return (current == picked) ? OverlayEnv::kNone : picked;
} }
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled) { OverlayEnv overlayEnvForModeToggle(int toggleId) {
switch (static_cast<DeckParam>(toggleId)) {
case DeckParam::kAmpEnvMode: return OverlayEnv::kAmp;
case DeckParam::kPitchEnvMode: return OverlayEnv::kPitch;
case DeckParam::kFilterEnvMode: return OverlayEnv::kFilter;
default: return OverlayEnv::kNone;
}
}
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state) {
switch (env) { switch (env) {
case OverlayEnv::kPitch: return !pitchEnvEnabled; case OverlayEnv::kPitch: return state.pitchEnvEnabled;
case OverlayEnv::kFilter: return !filterEnabled; case OverlayEnv::kFilter: return state.filterEnabled;
case OverlayEnv::kAmp: case OverlayEnv::kAmp:
case OverlayEnv::kNone:
return true;
}
return true; // unreachable for a valid enumerator; silences a warning.
}
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state) {
if (env == OverlayEnv::kNone) return false;
if (!overlayEnvEnabled(env, state)) return true;
switch (env) {
case OverlayEnv::kPitch: return state.pitchSpline;
case OverlayEnv::kFilter: return state.filterSpline;
case OverlayEnv::kAmp: return state.ampSpline;
case OverlayEnv::kNone: case OverlayEnv::kNone:
return false; return false;
} }
return false; // unreachable for a valid enumerator; silences a warning. return false; // unreachable for a valid enumerator; silences a warning.
} }
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) { bool deckKnobInert(DeckParam id, const DeckEnableState& state) {
switch (id) { switch (id) {
case DeckParam::kAttack:
case DeckParam::kHold:
case DeckParam::kDecay:
case DeckParam::kSustain:
case DeckParam::kRelease:
case DeckParam::kTrigAttack:
case DeckParam::kTrigHold:
case DeckParam::kTrigDecay:
return state.ampSpline;
// The Trigger %-length knob is inert whenever ANY spline is active (not just the amp's):
// a drawn contour always covers the full sample length (splineActive, play_params.h), so
// the engine ignores lengthFraction in that case regardless of which EG is drawn.
case DeckParam::kTrigLength:
return state.ampSpline || state.pitchSpline || state.filterSpline;
case DeckParam::kPitchEnvAttack: case DeckParam::kPitchEnvAttack:
case DeckParam::kPitchEnvHold: case DeckParam::kPitchEnvHold:
case DeckParam::kPitchEnvDecay: case DeckParam::kPitchEnvDecay:
return !state.pitchEnvEnabled || state.pitchSpline;
case DeckParam::kPitchEnvDepth: case DeckParam::kPitchEnvDepth:
return !pitchEnvEnabled; return !state.pitchEnvEnabled;
case DeckParam::kFilterEnvAttack:
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !state.filterEnabled || state.filterSpline;
case DeckParam::kFilterMorph: case DeckParam::kFilterMorph:
case DeckParam::kFilterCutoff: case DeckParam::kFilterCutoff:
case DeckParam::kFilterQ: case DeckParam::kFilterQ:
@@ -257,15 +319,7 @@ bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled) {
// The filter's velocity curve sits in the VELOCITY group but is a filter parameter: // 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. // it goes inert with every other one, so no surface can reach a param the knobs can't.
case DeckParam::kFilterVelCurve: case DeckParam::kFilterVelCurve:
case DeckParam::kFilterEnvAttack: return !state.filterEnabled;
case DeckParam::kFilterEnvHold:
case DeckParam::kFilterEnvDecay:
case DeckParam::kFilterEnvSustain:
case DeckParam::kFilterEnvRelease:
case DeckParam::kFilterTrigAttack:
case DeckParam::kFilterTrigHold:
case DeckParam::kFilterTrigDecay:
return !filterEnabled;
default: default:
return false; return false;
} }
+36 -10
View File
@@ -74,6 +74,11 @@ enum class DeckParam {
kAmpEnvSelect, kAmpEnvSelect,
kPitchEnvSelect, kPitchEnvSelect,
kFilterEnvSelect, kFilterEnvSelect,
// Staged | Spline mode per envelope. Both states persist either way (play_params.h's
// SplineEnv); this only picks which one plays and which one the overlay edits.
kAmpEnvMode,
kPitchEnvMode,
kFilterEnvMode,
// Deck-only controls: processor-side per-instance params — routed to the processor // Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through the parameter set. // setters, never through the parameter set.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
@@ -106,8 +111,8 @@ CurveTarget curveTargetFor(int controlId);
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the // 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 // 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 // Gate, AHD in Trigger — via knob_deck's cell-width reservation (knob_deck.h) so a mode flip
// never reflows the neighbouring groups. // never reflows the neighbouring groups and never leaves a hole in the narrower face.
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode); std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
// The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes // The curve-exponent control a stage knob's INNER DIAL edits, or kCount when the knob shapes
@@ -163,17 +168,38 @@ OverlayEnv overlayEnvForRadio(int radioId);
// to, not an error. A non-radio id leaves the selection alone. // to, not an error. A non-radio id leaves the selection alone.
OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId); OverlayEnv nextOverlaySelection(OverlayEnv current, int radioId);
// Whether the overlay for `env` is INERT: its deck group's enable toggle is off, so its knobs // The group states the two inert predicates below read. One struct rather than a growing
// are drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches // parameter list, so adding a gate is a change at the two predicates and nowhere else.
// a param a knob couldn't (envelope_edit.h). Amp has no enable toggle and is never inert. struct DeckEnableState {
bool overlayEnvInert(OverlayEnv env, bool pitchEnvEnabled, bool filterEnabled); bool pitchEnvEnabled = false;
bool filterEnabled = false;
bool ampSpline = false; // the amp EG is drawn rather than staged
bool pitchSpline = false;
bool filterSpline = false;
};
// Which envelope a Staged|Spline mode toggle belongs to; kNone for any other control id.
OverlayEnv overlayEnvForModeToggle(int toggleId);
// Whether `env`'s deck group is switched on at all. Amp has no enable toggle and is always on.
// The gate BOTH overlay modes share — a disabled group's contour is as dead as its knobs.
bool overlayEnvEnabled(OverlayEnv env, const DeckEnableState& state);
// Whether the STAGED overlay for `env` is INERT: its deck group is off, so its knobs are
// drawn-but-dead and a node drag on the same params must be too — otherwise a drag reaches a
// param a knob couldn't (envelope_edit.h). An envelope in SPLINE mode is inert here too: the
// staged nodes are not what the overlay is editing.
bool overlayEnvInert(OverlayEnv env, const DeckEnableState& state);
// Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is // Whether a deck knob cell is drawn-but-dead: the pitch envelope's four knobs while it is
// disabled, and the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter // disabled, the filter group's tone/modulation knobs (plus its VELOCITY cell, a filter
// parameter that just sits in that group) while the filter is disabled. Every other id is // parameter that just sits in that group) while the filter is disabled, and every STAGED
// always live. Mirrors overlayEnvInert's group-toggle-gates-its-knobs shape for the deck's own // SEGMENT knob of an envelope switched to Spline. The segment knobs' inner curve dials go with
// them — the dial is reached through its outer cell, so one predicate covers both. The DEPTH
// knobs (pitch peak, filter mod amount) stay live in either mode: they scale whichever shape is
// active rather than describing a stage. Mirrors overlayEnvInert's shape for the deck's own
// mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this). // mouse-down/paint (the shell's deckKnobDisabled is a thin int-id wrapper over this).
bool deckKnobInert(DeckParam id, bool pitchEnvEnabled, bool filterEnabled); bool deckKnobInert(DeckParam id, const DeckEnableState& state);
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and // The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at // +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
+37 -20
View File
@@ -24,6 +24,7 @@ int knobRowWidth(const DeckGroupDesc& g) {
int captionRowWidth(const DeckGroupDesc& g) { int captionRowWidth(const DeckGroupDesc& g) {
int w = g.captionWidth; int w = g.captionWidth;
if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth; if (g.captionToggle.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle.segWidth;
if (g.captionToggle2.id >= 0) w += kDeckToggleGap + 2 * g.captionToggle2.segWidth;
if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize; if (g.captionRadio.id >= 0) w += kDeckToggleGap + kDeckRadioSize;
return w; return w;
} }
@@ -49,25 +50,38 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
captionRight = out.captionRadio.box.x - kDeckToggleGap; captionRight = out.captionRadio.box.x - kDeckToggleGap;
out.caption.width = captionRight - out.caption.x; out.caption.width = captionRight - out.caption.x;
} }
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2; const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
if (d.id < 0) return;
const int segW = d.segWidth;
const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight, const Rect seg1 = Rect::ltrb(captionRight - segW, togTop, captionRight,
togTop + kDeckToggleH); togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH); const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1}; into = DeckToggleLayout{d.id, seg0, seg1};
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width). captionRight = seg0.x - kDeckToggleGap;
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x; // Caption text stops at the leftmost toggle: pull the right edge in (XYWH: width).
} out.caption.width = captionRight - out.caption.x;
};
placeToggle(g.captionToggle, out.captionToggle);
placeToggle(g.captionToggle2, out.captionToggle2);
// Knob row: fixed cells left-to-right, then the optional row toggle. // Knob row: the cells present divide the whole reserved run (one kDeckCellW per declared
// id, reserves included). Integer division puts an indivisible residue in symmetric end
// margins rather than in one odd-width cell — keyboard_strip's uniformity-wins rule.
const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap; const int cellTop = captionTop + kDeckCaptionH + kDeckCaptionGap;
int x = innerLeft; const int runWidth = static_cast<int>(g.cellIds.size()) * kDeckCellW;
int presentCells = 0;
for (int id : g.cellIds) { for (int id : g.cellIds) {
if (id >= 0) ++presentCells;
}
const int cellW = presentCells > 0 ? runWidth / presentCells : 0;
int x = innerLeft + (runWidth - presentCells * cellW) / 2;
for (int id : g.cellIds) {
if (id < 0) continue;
DeckCellLayout c; DeckCellLayout c;
c.id = id; c.id = id;
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH); c.cell = Rect::ltrb(x, cellTop, x + cellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2; const int knobLeft = x + (cellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4; const int knobTop = cellTop + 4;
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize); c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2; const int innerLeftPx = knobLeft + (kDeckKnobSize - kDeckInnerDialSize) / 2;
@@ -77,13 +91,16 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
const int labelTop = knobTop + kDeckKnobSize + 4; const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH); c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c); out.cells.push_back(c);
x += kDeckCellW; x += cellW;
} }
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (!g.cellIds.empty()) x += kDeckToggleGap; // Anchored past the whole reserved run, not past the last cell, so a residue margin
// cannot shift it.
int tx = innerLeft + runWidth;
if (!g.cellIds.empty()) tx += kDeckToggleGap;
const int segW = g.rowToggle.segWidth; const int segW = g.rowToggle.segWidth;
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2; const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH); const Rect seg0 = Rect::ltrb(tx, togTop, tx + segW, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH); const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1}; out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
} }
@@ -151,11 +168,10 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) { if (g.captionRadio.id >= 0 && contains(g.captionRadio.box, x, y)) {
return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false}; return {DeckHitKind::CaptionRadio, g.captionRadio.id, -1, false};
} }
if (g.captionToggle.id >= 0) { for (const DeckToggleLayout* t : {&g.captionToggle, &g.captionToggle2}) {
if (contains(g.captionToggle.seg0, x, y)) if (t->id < 0) continue;
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 0}; if (contains(t->seg0, x, y)) return {DeckHitKind::CaptionToggle, t->id, 0};
if (contains(g.captionToggle.seg1, x, y)) if (contains(t->seg1, x, y)) return {DeckHitKind::CaptionToggle, t->id, 1};
return {DeckHitKind::CaptionToggle, g.captionToggle.id, 1};
} }
if (g.rowToggle.id >= 0) { if (g.rowToggle.id >= 0) {
if (contains(g.rowToggle.seg0, x, y)) if (contains(g.rowToggle.seg0, x, y))
@@ -164,11 +180,12 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {DeckHitKind::RowToggle, g.rowToggle.id, 1}; return {DeckHitKind::RowToggle, g.rowToggle.id, 1};
} }
for (const DeckCellLayout& c : g.cells) { for (const DeckCellLayout& c : g.cells) {
if (c.id >= 0 && contains(c.cell, x, y)) { // Every entry here already has a real id — a reserve yields no DeckCellLayout at all.
if (contains(c.cell, x, y)) {
return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)}; return {DeckHitKind::Knob, c.id, -1, contains(c.inner, x, y)};
} }
} }
return {}; // inside the box but on fence/padding/blank — a miss (groups never overlap) return {}; // inside the box but on fence/padding — a miss (groups never overlap)
} }
return {}; return {};
} }
+12 -6
View File
@@ -5,9 +5,9 @@
// //
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob // caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// row of fixed cells (knob centered, label band beneath). A group may also place one // row of equal-width cells (knob centered, label band beneath). A group may also place one
// two-segment toggle in the knob row after its cells. Groups that must keep stable // two-segment toggle in the knob row after its cells. Groups that must keep stable
// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows // geometry across a mode flip reserve cell width (id -1) so a mode flip never reflows
// neighbouring groups. // neighbouring groups.
// //
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
@@ -57,14 +57,19 @@ struct DeckRadioDesc {
}; };
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1 // One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell // reserves one cell's WIDTH without a cell, and the cells present divide the whole run —
// see this module's CLAUDE.md bullet for what that buys. `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text). // reserves for the caption text (this module does not measure text).
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none DeckRadioDesc captionRadio; // the caption row's far corner; id -1 = none
DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none DeckToggleDesc captionToggle; // caption row, left of the radio; id -1 = none
std::vector<int> cellIds; // knob cells; -1 = blank reserve // A second caption toggle, placed immediately left of the first (or in its place when the
// first is absent) — why this exists rather than a rowToggle is recorded once, at this
// module's CLAUDE.md bullet.
DeckToggleDesc captionToggle2;
std::vector<int> cellIds; // knob cells; -1 reserves width only, no cell (see above)
DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none DeckToggleDesc rowToggle; // in the knob row after the cells; id -1 = none
}; };
@@ -83,7 +88,7 @@ struct DeckRadioLayout {
struct DeckCellLayout { struct DeckCellLayout {
int id = -1; int id = -1;
Rect cell; // the full 48x58 cell Rect cell; // the whole cell; width is the group's reserved run divided by its cell count
Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it) Rect knob; // the centered kDeckKnobSize square (the knob circle inscribes it)
Rect inner; // the concentric kDeckInnerDialSize square inside `knob` Rect inner; // the concentric kDeckInnerDialSize square inside `knob`
Rect label; // the 12px label band beneath the knob Rect label; // the 12px label band beneath the knob
@@ -95,6 +100,7 @@ struct DeckGroupLayout {
Rect caption; // caption text rect (left part of the caption row) Rect caption; // caption text rect (left part of the caption row)
DeckRadioLayout captionRadio; // id -1 when absent (rect empty) DeckRadioLayout captionRadio; // id -1 when absent (rect empty)
DeckToggleLayout captionToggle; // id -1 when absent (rects empty) DeckToggleLayout captionToggle; // id -1 when absent (rects empty)
DeckToggleLayout captionToggle2;
std::vector<DeckCellLayout> cells; std::vector<DeckCellLayout> cells;
DeckToggleLayout rowToggle; // id -1 when absent DeckToggleLayout rowToggle; // id -1 when absent
}; };
@@ -136,7 +142,7 @@ struct DeckHit {
// The deck element a point lands on: a knob cell (the whole cell, not just the knob // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking // circle — the shell anchors the vertical drag wherever the grab lands, with `inner` marking
// a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or // a grab on the concentric inner dial), a caption-toggle segment, a row-toggle segment, or
// the caption-row corner radio. Blank cells (id -1) and everything else miss. // the caption-row corner radio. Everything else — fence, padding, outside — misses.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+39
View File
@@ -0,0 +1,39 @@
// spline_edit.cpp — see spline_edit.h. Pure decision logic; no host types.
#include "core/instrument/ui/spline_edit.h"
namespace reasampler::instrument::ui {
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y) {
if (box.width <= 0 || box.height <= 1) return {};
const int idx = curve.pointAtPixel(box, x, y);
switch (gesture) {
case SplineGesture::kRight:
return idx >= 0 ? SplineEdit{SplineEditKind::kDelete, idx} : SplineEdit{};
case SplineGesture::kControlLeft:
return idx >= 0 ? SplineEdit{SplineEditKind::kToggleHard, idx} : SplineEdit{};
case SplineGesture::kLeft:
break;
}
if (idx >= 0) return {SplineEditKind::kGrab, idx};
const bool inBox = (x >= box.left && x < box.left + box.width && y >= box.top &&
y < box.top + box.height);
return inBox ? SplineEdit{SplineEditKind::kAdd, -1} : SplineEdit{};
}
VelocityCurve::Box splineOverlayBox(const OverlayArea& area) {
return VelocityCurve::Box{area.rect.x, area.rect.y, area.rect.width, area.rect.height};
}
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
const WaveformClaim& marker, SplineGesture gesture) {
if (gesture == SplineGesture::kControlLeft && node.hit) return WaveformClaimant::kNode;
if (node.hit && (!tab.hit || node.area <= tab.area) && (!marker.hit || node.area <= marker.area))
return WaveformClaimant::kNode;
if (tab.hit && (!marker.hit || tab.area <= marker.area)) return WaveformClaimant::kTab;
if (marker.hit) return WaveformClaimant::kMarker;
return WaveformClaimant::kNone;
}
} // namespace reasampler::instrument::ui
+80
View File
@@ -0,0 +1,80 @@
// spline_edit.h — the point-editing grammar's CLICK resolution: add/grab/delete/toggle from a
// single (x, y), plus resolveWaveformClaim, the waveform overlay's cross-affordance arbitration
// (node vs. crossfade tab vs. marker). Both spline consumers — the velocity-curve popup and the
// spline EG overlay — route their mouse-down through the click grammar, so the two cannot drift
// apart. Decision logic only, no host types, no drawing; mirror of envelope_edit otherwise.
#pragma once
#include <cstdint>
#include "core/instrument/engine/velocity_curve.h"
#include "core/instrument/ui/editor_geometry.h" // Rect / OverlayArea
namespace reasampler::instrument::ui {
using engine::VelocityCurve;
// The gesture, in the pure module's own vocabulary (the shell maps its modifier state onto it).
enum class SplineGesture { kLeft, kRight, kControlLeft };
// What the gesture resolves to. Left-click adds a point in empty space and grabs an existing
// one; right-click deletes; control-click toggles hard/smooth. Points are smooth by default.
enum class SplineEditKind { kNone, kGrab, kAdd, kDelete, kToggleHard };
struct SplineEdit {
SplineEditKind kind = SplineEditKind::kNone;
int index = -1; // the point the action targets; -1 for kAdd (it has none yet) and kNone
};
// Resolves a click at (x, y) over `box` into an edit. The endpoint and point-count rules are
// NOT re-stated here — kDelete on an endpoint and kAdd at the ceiling are refused by
// VelocityCurve::deletePoint / addPoint, which the caller applies, so there is exactly one home
// for each. A click outside the mapping box resolves to kNone unless it lands on a node's pick
// radius: the drawn inset ring must not ADD (the new point would clamp onto an endpoint's x and
// stack an undeletable duplicate) but must still be able to grab.
SplineEdit resolveSplineEdit(const VelocityCurve& curve, const VelocityCurve::Box& box,
SplineGesture gesture, int x, int y);
// The contour's mapping box inside the waveform overlay: the FULL area, so the drawn contour
// spans the whole sample width 1:1 with its time axis. No inset — unlike the popup's box, which
// insets to keep endpoint handles clear of the sheet border, this one must stay 1:1 with the
// waveform beneath it. Takes the overlay (not a lane) — see waveform_view.h's overlay contract.
VelocityCurve::Box splineOverlayBox(const OverlayArea& area);
// Two more rules complete the grammar. Both are enforced in the shell — mouse-tracking / drag
// state has no home in a pure module — and recorded here as their one home rather than restated
// at each call site:
// - DRAG-OFF DELETE: releasing a grabbed node well outside its box deletes it (endpoints exempt,
// per deletePoint's own refusal) — editor_input.cpp's onMouseUp, shared verbatim by the popup
// and the overlay.
// - THE OVERLAY'S OUTSIDE-BOX EXCEPTION: resolveSplineEdit's own outside-box grab/toggle/delete
// allowance (above) is meant for the popup's inset ring; the overlay narrows it back to
// strictly in-box, since splineOverlayBox has no inset — editor_input_waveform.cpp's
// splineOverlayClick.
// One arbitration candidate: whether the affordance was hit under the cursor, and its own
// pick-target area (nominal, per its own module's constants — not the actual clipped pixel
// count; see resolveWaveformClaim).
struct WaveformClaim {
bool hit = false;
std::int64_t area = 0;
};
// Which affordance a waveform-overlay click claims.
enum class WaveformClaimant { kNone, kNode, kTab, kMarker };
// The overlay's cross-affordance arbitration: a contour node (or, mutually exclusively, a
// staged envelope's drag node — both feed the same `node` slot), the loop crossfade tab, and a
// marker's full-height column can all claim the same pixel. Hit gates a candidate out
// entirely; among the ones that hit, the SMALLEST nominal area wins — the marker column is the
// odd one out (its target is the whole overlay height), so it only wins where nothing narrower
// also claims the click. Ties go to whichever is checked first: node, then tab, then marker —
// no live geometry produces a tie except tab-vs-marker, which the tab correctly wins (see
// editor_input_waveform.cpp's mouseDownWaveform for the live constants). A control-click has no
// tab/marker meaning (they answer plain grabs only), so it resolves to the node whenever the
// node is in the running, regardless of area.
WaveformClaimant resolveWaveformClaim(const WaveformClaim& node, const WaveformClaim& tab,
const WaveformClaim& marker, SplineGesture gesture);
} // namespace reasampler::instrument::ui
+2 -2
View File
@@ -11,7 +11,7 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and `deck_groups`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it. `core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants ## Invariants
@@ -101,7 +101,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant. - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the control-value domain maps + the node-drag bounds that must match them, plus the ONE `faceLayout` band resolve every paint and hit-test path shares), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select). - `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
+2 -1
View File
@@ -51,6 +51,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# matching sets, plus the two band-independent surfaces and the platform TU. # matching sets, plus the two band-independent surfaces and the platform TU.
editor_session.cpp editor_session.cpp
editor_controls.cpp editor_controls.cpp
editor_models.cpp
editor_paint.cpp editor_paint.cpp
editor_paint_chrome.cpp editor_paint_chrome.cpp
editor_paint_waveform.cpp editor_paint_waveform.cpp
@@ -85,7 +86,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
capture_browser keyboard_strip sample_bands sample_chrome capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider tooltip waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups curve_popup master_gain sample_usage file_bytes curve_law) knob_deck deck_groups curve_popup spline_edit master_gain sample_usage file_bytes curve_law)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives # SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC. # LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC}) target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
+45 -144
View File
@@ -1,9 +1,9 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout // editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue / // resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding // applyControl — seconds/fraction/frames <-> normalized 0..1), the control-id<->value binding
// against the pure `deck_groups` module's descriptors, and the envelope pack/unpack (which // against the pure `deck_groups` module's descriptors, and the node-drag clamp bounds that must
// stored struct each overlay selection maps onto). Value logic only — no painting, no window // match those domains. The orthogonal half — which stored struct each editor selection names —
// plumbing. // is editor_models. Value logic only: no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h" #include "shell/instrument/reasampler_editor.h"
@@ -11,12 +11,10 @@
#include <cstdint> #include <cstdint>
#include <cstdio> // snprintf (deck value labels) #include <cstdio> // snprintf (deck value labels)
#include <string> #include <string>
#include <utility> // std::as_const (the const/non-const editedCurve pair)
#include <vector> #include <vector>
#include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws #include "core/instrument/engine/filter/filter_params.h" // the filter's own control laws
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper #include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span)
#include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition) #include "core/instrument/ui/deck_groups.h" // sampleDeckGroups (the deck's composition)
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height) #include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h" #include "core/util/clamp01.h"
@@ -26,7 +24,7 @@
namespace reasampler::vst { namespace reasampler::vst {
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam using namespace reasampler::instrument::map; // PlaySeconds vocabulary
using instrument::ui::computeSampleBands; using instrument::ui::computeSampleBands;
using instrument::ui::chromeRects; using instrument::ui::chromeRects;
using instrument::ui::deckHeight; using instrument::ui::deckHeight;
@@ -99,6 +97,12 @@ double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
switch (static_cast<ParamControl>(id)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kAmpEnvMode:
return play.ampSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEnvMode:
return play.pitchSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kFilterEnvMode:
return play.filterSpline.mode == EnvMode::Spline ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
@@ -164,8 +168,25 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
switch (static_cast<ParamControl>(id)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: case ParamControl::kPlayMode:
// Gate is refused while any EG is drawn — see splineActive (play_params.h). The
// segment paints Disabled for the same reason, so the refusal is never a surprise.
if (segment == 0 && splineActive(play)) break;
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
break; break;
// Switching TO Spline drops Gate, which the spline model has no place for. Switching
// back does NOT restore it: the previous mode is not stored, and silently re-gating an
// instrument the user has since heard as a one-shot is the worse surprise.
case ParamControl::kAmpEnvMode:
case ParamControl::kPitchEnvMode:
case ParamControl::kFilterEnvMode: {
const EnvMode m = (segment == 1) ? EnvMode::Spline : EnvMode::Staged;
switch (static_cast<ParamControl>(id)) {
case ParamControl::kAmpEnvMode: play.ampSpline.mode = m; break;
case ParamControl::kPitchEnvMode: play.pitchSpline.mode = m; break;
default: play.filterSpline.mode = m; break;
}
break;
}
case ParamControl::kPitchEngine: case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break; break;
@@ -249,6 +270,12 @@ void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break; play.filter.trigEnv.decayCurve = util::curveFromKnobNorm(value); break;
default: break; default: break;
} }
// ONE normalization point for every control that can flip splineActive — a mode toggle
// (above) or an enable toggle (kPitchEnvEnable/kFilterEnable), whose enabling can make an
// already-Spline pitch/filter envelope newly active. Applying it once here, rather than at
// each site that could cause the flip, is what keeps a future such control from reopening
// the same hole. `resolvePlay` (sample_map.cpp) is the other caller of the shared helper.
enforceGateUnavailableWhileDrawn(play);
} }
double ReaSamplerEditor::liveSampleRate() const { double ReaSamplerEditor::liveSampleRate() const {
@@ -275,6 +302,15 @@ double ReaSamplerEditor::deckControlNorm(int id) const {
} }
} }
instrument::ui::DeckEnableState ReaSamplerEditor::deckEnableState() const {
const PlaySeconds& play = params_.play;
return instrument::ui::DeckEnableState{
play.pitchEnv.enabled, play.filter.enabled,
play.ampSpline.mode == EnvMode::Spline,
play.pitchSpline.mode == EnvMode::Spline,
play.filterSpline.mode == EnvMode::Spline};
}
void ReaSamplerEditor::applyDeckKnob(int id, double norm) { void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
if (!processor_) return; if (!processor_) return;
norm = clamp01(norm); norm = clamp01(norm);
@@ -409,9 +445,9 @@ std::string ReaSamplerEditor::deckValueLabel(int id) const {
} }
EnvClampBounds ReaSamplerEditor::envClampBounds() const { EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the deck knobs' own domains so a node drag can never produce a param a knob // Lives here, beside controlValue/applyControl, because it must MATCH them: a node drag can
// couldn't. Every stage time caps at kEnvTimeMaxSeconds; the Hold fractions and the sustain // never produce a param a knob couldn't. Every stage time caps at kEnvTimeMaxSeconds; the
// level are [0,1] by definition and need no bound here. // Hold fractions and the sustain level are [0,1] by definition and need no bound here.
EnvClampBounds b; EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds; b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds; b.maxHoldSeconds = kEnvTimeMaxSeconds;
@@ -420,141 +456,6 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
return b; return b;
} }
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
const double playSpan = rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0;
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const {
switch (target) {
case CurveTarget::kPitch: return params_.play.pitchVelocityCurve;
case CurveTarget::kFilter: return params_.play.filter.velocityCurve;
case CurveTarget::kAmp:
case CurveTarget::kNone:
// kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a
// disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable
// overload below refuses kNone rather than relying on this fallback to protect amp.
return params_.velocityCurve;
}
return params_.velocityCurve;
}
VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) {
return const_cast<VelocityCurve&>(std::as_const(*this).curveFor(target));
}
const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); }
VelocityCurve& ReaSamplerEditor::editedCurve() {
// Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be
// unreachable now that every writer of kNone also cancels the drag, but this is the second,
// independent line of defense.
if (curvePopup_ == CurveTarget::kNone) {
static VelocityCurve sink = VelocityCurve::flat();
return sink;
}
return curveFor(curvePopup_);
}
void ReaSamplerEditor::closeCurvePopup() {
curvePopup_ = CurveTarget::kNone;
if (drag_ == DragKind::kCurveNode) {
drag_ = DragKind::kNone;
curvePointIndex_ = -1;
}
}
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) { if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1. // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
+10 -5
View File
@@ -112,17 +112,22 @@ void ReaSamplerEditor::onMouseUp(int x, int y) {
invalidate(); invalidate();
return; return;
} }
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged // Drag-off delete: releasing a curve-node drag well outside its box removes the dragged
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move — // point on EITHER spline surface — the popup's kCurveNode and the overlay's kSplineNode
// its amp keeps the last clamped drag value). // share this grammar, not just their click grammar (deletePoint refuses the two endpoints,
if (kind == DragKind::kCurveNode && curveIdx >= 0) { // so an endpoint drag-off is a plain move — its amp keeps the last clamped drag value).
if ((kind == DragKind::kCurveNode || kind == DragKind::kSplineNode) && curveIdx >= 0) {
const bool off = x < curveRect.x - kCurveDragOffMargin || const bool off = x < curveRect.x - kCurveDragOffMargin ||
x > curveRect.right() + kCurveDragOffMargin || x > curveRect.right() + kCurveDragOffMargin ||
y < curveRect.y - kCurveDragOffMargin || y < curveRect.y - kCurveDragOffMargin ||
y > curveRect.bottom() + kCurveDragOffMargin; y > curveRect.bottom() + kCurveDragOffMargin;
if (off) { if (off) {
if (kind == DragKind::kCurveNode) {
editedCurve().deletePoint(static_cast<std::size_t>(curveIdx)); editedCurve().deletePoint(static_cast<std::size_t>(curveIdx));
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node } else {
splineFor(overlayEnv_).deletePoint(static_cast<std::size_t>(curveIdx));
}
hover_ = HoverTarget{}; // stale node index would light a shifted node
} }
} }
commitAndReload(); commitAndReload();
+33 -32
View File
@@ -1,6 +1,7 @@
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing, // editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click // node grab/add/toggle/delete inside the curve box, the live node drag, the right-click
// delete, and the popup's hover. Band-independent (the sheet floats over the whole face). // delete on EITHER spline surface, and the popup's hover. Band-independent (the sheet floats
// over the whole face).
// Windows-only. // Windows-only.
#include "shell/instrument/reasampler_editor.h" #include "shell/instrument/reasampler_editor.h"
@@ -8,6 +9,8 @@
#ifdef _WIN32 #ifdef _WIN32
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet #include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea (the overlay right-click)
#include "shell/instrument/editor_internal.h" // curveBoxFromRect #include "shell/instrument/editor_internal.h" // curveBoxFromRect
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -42,36 +45,27 @@ void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
const VelocityCurve::Box box = curveBoxFromRect(r); const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return; if (box.width <= 0 || box.height <= 1) return;
int idx = editedCurve().pointAtPixel(box, x, y); // Alt-click delete is retired (the spec's right-click supersedes it — one grammar, no
// migration on either side): every gesture here routes through the shared resolver.
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once const bool ctrl = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op). const SplineEdit edit = resolveSplineEdit(
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) { editedCurve(), box, ctrl ? SplineGesture::kControlLeft : SplineGesture::kLeft, x, y);
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) { if (edit.kind == SplineEditKind::kToggleHard) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node if (editedCurve().toggleHard(static_cast<std::size_t>(edit.index))) commitAndReload();
commitAndReload();
}
return; return;
} }
if (edit.kind == SplineEditKind::kNone) return; // ring click with no node hit
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD // Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
// (mirror of the other parameter-editing drags' dragStartParams_ contract). // (mirror of the other parameter-editing drags' dragStartParams_ contract).
dragStartParams_ = params_; dragStartParams_ = params_;
// Empty-space click inside the mapping box: add a control point via the pure inverse map, int idx = edit.index;
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a if (edit.kind == SplineEditKind::kAdd) {
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
// endpoint. A ring click can still grab an existing node (handled above).
if (idx < 0) {
const bool inBox = (x >= box.left && x < box.left + box.width &&
y >= box.top && y < box.top + box.height);
if (inBox) {
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y); const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
idx = static_cast<int>(editedCurve().addPoint(p.velocity, p.value)); idx = editedCurve().addPoint(p.velocity, p.value);
if (idx < 0) return; // at the point ceiling: refused, curve untouched
} }
}
if (idx < 0) return; // ring click with no node hit — nothing to grab
drag_ = DragKind::kCurveNode; drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx; curvePointIndex_ = idx;
@@ -94,23 +88,30 @@ void ReaSamplerEditor::dragCurve(int x, int y) {
} }
void ReaSamplerEditor::onMouseRDown(int x, int y) { void ReaSamplerEditor::onMouseRDown(int x, int y) {
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click // Right-click deletes a node on EITHER spline surface — the primary delete affordance;
// and drag-off remain as landed alternates. Commits immediately through the same path as // Alt-click and drag-off remain as landed alternates in the popup. deletePoint's endpoint
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op. // guard makes an endpoint right-click a safe no-op. Never acts during an in-flight left
// Right-clicks act only while the popup is open, and never during an in-flight left drag. // drag; the modal popup wins when it is open.
if (!processor_ || view_ == View::kBrowse || curvePopup_ == CurveTarget::kNone) return; if (!processor_ || view_ == View::kBrowse) return;
if (drag_ != DragKind::kNone) return; if (drag_ != DragKind::kNone) return;
RECT rc{}; RECT rc{};
GetClientRect(childHwnd_, &rc); GetClientRect(childHwnd_, &rc);
if (curvePopup_ != CurveTarget::kNone) {
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top); const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return; if (!contains(pl.curveBox, x, y)) return;
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox); const SplineEdit edit = resolveSplineEdit(editedCurve(), curveBoxFromRect(pl.curveBox),
const int idx = editedCurve().pointAtPixel(box, x, y); SplineGesture::kRight, x, y);
if (idx < 0) return; if (edit.kind != SplineEditKind::kDelete) return;
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) { if (editedCurve().deletePoint(static_cast<std::size_t>(edit.index))) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload(); commitAndReload();
} }
return;
}
if (!overlayIsSpline() || !overlayEnvEnabled(overlayEnv_, deckEnableState())) return;
const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top);
splineOverlayClick(waveformOverlayArea(fl.bands.waveform), x, y, SplineGesture::kRight,
/*addOnEmptySpace=*/false);
} }
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x, ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
+11 -4
View File
@@ -19,8 +19,7 @@ using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::deckKnobDisabled(int id) const { bool ReaSamplerEditor::deckKnobDisabled(int id) const {
// Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert // Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert
// (deck_groups.h) for which cells go inert and why. // (deck_groups.h) for which cells go inert and why.
return deckKnobInert(static_cast<DeckParam>(id), params_.play.pitchEnv.enabled, return deckKnobInert(static_cast<DeckParam>(id), deckEnableState());
params_.play.filter.enabled);
} }
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
@@ -64,12 +63,20 @@ bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
applyParamControl(hit.id, 0.0, hit.segment); applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload(); commitAndReload();
break; break;
default: default: {
// Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable). // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable,
// and the three env-mode toggles).
applyParamControl(hit.id, 0.0, hit.segment); applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload(); commitAndReload();
// Flipping an EG's Staged|Spline toggle makes THAT envelope's overlay active,
// so the contour (or the staged shape you just returned to) is what's drawn.
// overlayEnvForModeToggle answers kNone for every other toggle this default
// case handles, which is why the assignment is conditional.
const OverlayEnv modeEnv = overlayEnvForModeToggle(hit.id);
if (modeEnv != OverlayEnv::kNone) overlayEnv_ = modeEnv;
break; break;
} }
}
return true; return true;
} }
if (hit.kind == DeckHitKind::Knob) { if (hit.kind == DeckHitKind::Knob) {
+140 -24
View File
@@ -13,7 +13,9 @@
#include <vector> #include <vector>
#include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound) #include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound)
#include "core/instrument/engine/velocity_curve.h" // kCurveNodeGrabRadius (target-size arbitration)
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag #include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap #include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
#include "shell/instrument/editor_internal.h" #include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -23,6 +25,7 @@ namespace reasampler::vst {
using namespace reasampler::ui; using namespace reasampler::ui;
using namespace reasampler::instrument::ui; using namespace reasampler::instrument::ui;
using instrument::engine::loop::maxCrossfade; using instrument::engine::loop::maxCrossfade;
using instrument::engine::kCurveNodeGrabRadius;
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_); const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
@@ -30,21 +33,78 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
if (frames <= 0) return false; if (frames <= 0) return false;
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform); const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
// Envelope nodes first (they sit on top of the markers), then the wave markers. With no const DeckEnableState gates = deckEnableState();
// envelope overlay-active — or with its deck group's enable toggle off, which makes the const bool splineLive = overlayIsSpline() && overlayEnvEnabled(overlayEnv_, gates);
// same params' knobs inert — there are no grabbable nodes and the markers take every grab. const SplineGesture gesture = (GetKeyState(VK_CONTROL) & 0x8000) != 0
? SplineGesture::kControlLeft
: SplineGesture::kLeft;
// The staged envelope's draggable node and the drawn contour's node are mutually exclusive
// (overlayEnvInert flips the staged one inert exactly when its envelope is in Spline mode),
// so at most one of the two hit-tests below is ever live for the same click — both feed the
// SAME arbitration slot below rather than either one getting its own check-order return.
const double rate = liveSampleRate(); const double rate = liveSampleRate();
const bool nodesLive = const bool nodesLive =
overlayEnv_ != OverlayEnv::kNone && overlayEnv_ != OverlayEnv::kNone && !overlayEnvInert(overlayEnv_, gates);
!overlayEnvInert(overlayEnv_, params_.play.pitchEnv.enabled, params_.play.filter.enabled); NodeHit envNodeHit;
StageEnvelope env;
if (rate > 0.0 && nodesLive) { if (rate > 0.0 && nodesLive) {
const std::int64_t startFrame = params_.startPoint.value_or(0); const std::int64_t startFrame = params_.startPoint.value_or(0);
const StageEnvelope env = packEnvelope(overlayEnv_, params_.play, frames, startFrame); env = packEnvelope(overlayEnv_, params_.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate; const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, overlay, totalSeconds, x, y); envNodeHit = nodeAtPoint(env, overlay, totalSeconds, x, y);
if (nh.hit) { }
const SetupMarkers m = pickedMarkers(frames);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
// Three affordances can claim the same pixel: a node (the staged envelope's or the drawn
// contour's — a small fixed pick box either way), the crossfade tab (a small clipped
// top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column
// split already keeps the tab apart from ITS OWN column; this is the cross-affordance case
// on top of that). resolveWaveformClaim (spline_edit.h) is the ONE arbitration: it measures
// each claimant's own NOMINAL target area and lets the smallest hit win, since a fixed check
// order shadows whichever one loses the tie — this seam regressed twice from exactly that
// fix. Never add here (kAdd is only tried once nothing else has claimed the click, below).
WaveformClaim node;
if (envNodeHit.hit) {
constexpr std::int64_t side = 2 * kNodeGrabRadius + 1;
node = {true, side * side};
} else if (splineLive) {
const VelocityCurve::Box box = splineOverlayBox(overlay);
// Also require strict in-box, matching splineOverlayClick's own narrowing (spline_edit.h's
// grammar note) — otherwise this candidate could "win" the arbitration below for a click
// splineOverlayClick would then refuse, silently swallowing it instead of falling through
// to the tab/marker checks.
if (contains(overlay.rect, x, y) && splineFor(overlayEnv_).pointAtPixel(box, x, y) >= 0) {
constexpr std::int64_t side = 2 * kCurveNodeGrabRadius + 1;
node = {true, side * side};
}
}
const Rect tabRect =
m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{};
const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y))
? WaveformClaim{true, static_cast<std::int64_t>(tabRect.width) *
tabRect.height}
: WaveformClaim{};
// Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at
// frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box
// corner. Both overestimate in the direction that already produces the intended winner, so
// the arbitration runs on NOMINAL area, not the measured hit-testable pixel count.
const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
const WaveformClaim marker =
(markerHit >= 0)
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
overlay.rect.height}
: WaveformClaim{};
switch (resolveWaveformClaim(node, tab, marker, gesture)) {
case WaveformClaimant::kNode:
if (envNodeHit.hit) {
drag_ = DragKind::kEnvNode; drag_ = DragKind::kEnvNode;
envNode_ = nh.node; envNode_ = envNodeHit.node;
dragStartX_ = x; dragStartX_ = x;
dragStartY_ = y; dragStartY_ = y;
dragStartEnv_ = env; dragStartEnv_ = env;
@@ -52,28 +112,73 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
dragStartParams_ = params_; dragStartParams_ = params_;
return true; // node moves once the cursor drags return true; // node moves once the cursor drags
} }
} return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
const SetupMarkers m = pickedMarkers(frames); case WaveformClaimant::kTab:
// The crossfade handle first, and only when there IS a loop to fade: at a zero fade it
// sits exactly on the loop start, so it can only stay reachable by owning the top strip
// (waveform_view.h's handle-vs-column split) and being asked first. The same ambiguity
// recurs whenever ANY marker's frame lands on loopStart - crossfade (most plausibly the
// start marker dragged up against the fade edge), so this check has to run before the
// marker array below regardless of which marker the collision is with.
if (m.hasLoop &&
contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) {
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x); beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
return true; return true;
} case WaveformClaimant::kMarker:
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
if (hit >= 0) {
beginMarkerDrag(static_cast<WaveMarker>(hit), m, frames, x);
return true; return true;
case WaveformClaimant::kNone:
break;
} }
// Nothing else wanted the click: now the drawn contour may take the empty space.
if (splineLive) return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/true);
return false; return false;
} }
bool ReaSamplerEditor::splineOverlayClick(const OverlayArea& waveArea, int x, int y,
SplineGesture gesture, bool addOnEmptySpace) {
const VelocityCurve::Box box = splineOverlayBox(waveArea);
VelocityCurve& contour = splineFor(overlayEnv_);
SplineEdit edit = resolveSplineEdit(contour, box, gesture, x, y);
// The overlay's outside-box narrowing — see spline_edit.h's grammar note for why.
if (edit.kind != SplineEditKind::kNone && edit.kind != SplineEditKind::kAdd &&
!(x >= box.left && x < box.left + box.width && y >= box.top && y < box.top + box.height)) {
edit = SplineEdit{};
}
if (edit.kind == SplineEditKind::kAdd && !addOnEmptySpace) return false;
switch (edit.kind) {
case SplineEditKind::kNone:
return false;
case SplineEditKind::kDelete:
// deletePoint refuses the two endpoints, so a right-click on one is a safe no-op.
if (!contour.deletePoint(static_cast<std::size_t>(edit.index))) return true;
hover_ = HoverTarget{}; // a stale index would light a shifted node
commitAndReload();
return true;
case SplineEditKind::kToggleHard:
if (!contour.toggleHard(static_cast<std::size_t>(edit.index))) return true;
commitAndReload();
return true;
case SplineEditKind::kAdd:
case SplineEditKind::kGrab:
break;
}
// Snapshot BEFORE the add so a capture-loss rollback cancels the in-flight point too
// (the same contract the other parameter-editing drags keep).
dragStartParams_ = params_;
int index = edit.index;
if (edit.kind == SplineEditKind::kAdd) {
const VelocityPoint p = contour.pointFromPixel(box, x, y);
index = contour.addPoint(p.velocity, p.value);
// At the ceiling: refused, contour untouched, nothing to roll back. Swallow the click
// rather than letting it fall through to a marker grab under the cursor.
if (index < 0) return true;
}
drag_ = DragKind::kSplineNode;
curvePointIndex_ = index;
dragStartCurve_ = contour; // AFTER the add — resolvePointDrag's delta base
// The release-time drag-off-delete bound (onMouseUp), matching the popup's kCurveNode use
// of the same field — the two spline surfaces share one drag-off grammar, not just the
// click grammar.
dragCurveRect_ = waveArea.rect;
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return true;
}
void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m, void ReaSamplerEditor::beginMarkerDrag(WaveMarker which, const SetupMarkers& m,
std::int64_t frames, int x) { std::int64_t frames, int x) {
drag_ = DragKind::kWaveMarker; drag_ = DragKind::kWaveMarker;
@@ -88,6 +193,17 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform); const OverlayArea overlay = waveformOverlayArea(fl.bands.waveform);
const int dx = x - dragStartX_; const int dx = x - dragStartX_;
if (drag_ == DragKind::kSplineNode) {
// Same absolute-delta contract as the popup's node drag, against the grab-time contour
// and the overlay's own box.
if (curvePointIndex_ < 0) return;
splineFor(overlayEnv_) = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
splineOverlayBox(overlay), dx, y - dragStartY_);
invalidate(); // live feedback; commit on release
return;
}
if (drag_ == DragKind::kEnvNode) { if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the // Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped), then unpack them back onto the parameter // pure envelope_edit inverse map, clamped), then unpack them back onto the parameter
+175
View File
@@ -0,0 +1,175 @@
// editor_models.cpp — the ReaSamplerEditor's model-selection half: which STORED struct each
// transient editor selection names. The overlay selection maps onto a staged envelope
// (pack/unpack) or a drawn contour; a curve-popup target maps onto one of the three velocity
// curves. One switch per family, so a moved field or a fourth curve is a one-place edit.
// Split from editor_controls, which owns the orthogonal half: the control-value domain maps.
#include "shell/instrument/reasampler_editor.h"
#include <cstdint>
#include <utility> // std::as_const (the const/non-const accessor pairs)
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the Trigger play span)
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam
namespace {
// The two directions of the AHDSR <-> StageEnvelope copy, so a field can only be forgotten in
// one place rather than two.
void packAhdsr(const AdsrSeconds& a, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahdsr;
env.attackSeconds = a.attackSeconds;
env.holdSeconds = a.holdSeconds;
env.decaySeconds = a.decaySeconds;
env.sustainLevel = a.sustainLevel;
env.releaseSeconds = a.releaseSeconds;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.releaseCurve = a.releaseCurve;
}
void unpackAhdsr(const StageEnvelope& env, AdsrSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.holdSeconds = env.holdSeconds;
a.decaySeconds = env.decaySeconds;
a.sustainLevel = env.sustainLevel;
a.releaseSeconds = env.releaseSeconds;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
a.releaseCurve = env.releaseCurve;
}
void packAhd(const AhdSeconds& a, double originSeconds, double spanSeconds, StageEnvelope& env) {
env.kind = instrument::ui::EnvKind::Ahd;
env.attackSeconds = a.attackSeconds;
env.decaySeconds = a.decaySeconds;
env.holdFraction = a.holdFraction;
env.attackCurve = a.attackCurve;
env.decayCurve = a.decayCurve;
env.originSeconds = originSeconds;
env.spanSeconds = spanSeconds;
}
void unpackAhd(const StageEnvelope& env, AhdSeconds& a) {
a.attackSeconds = env.attackSeconds;
a.decaySeconds = env.decaySeconds;
a.holdFraction = env.holdFraction;
a.attackCurve = env.attackCurve;
a.decayCurve = env.decayCurve;
}
} // namespace
StageEnvelope ReaSamplerEditor::packEnvelope(OverlayEnv which, const PlaySeconds& play,
std::int64_t frames,
std::int64_t startFrame) const {
StageEnvelope env;
const double rate = liveSampleRate();
const double t0 = rate > 0.0 ? static_cast<double>(startFrame) / rate : 0.0;
// The Trigger amp and filter AHDs live over the PLAY span; the pitch AHD over the whole
// post-start span, since it keeps running after a Trigger one-shot's amplitude has ended.
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
const double fullSpan =
rate > 0.0 ? static_cast<double>((std::max)(std::int64_t{0}, frames - startFrame)) / rate
: 0.0;
// Voice::start makes kTrigLength inert while any spline EG is active (trigSpan == postStart,
// not the %-length) — the overlay's amp/filter AHD must read the SAME span the engine plays,
// or its drawn shape and node drags cover only a fraction of what the note actually does.
const double playSpan =
splineActive(play) ? fullSpan : (rate > 0.0 ? static_cast<double>(playLen) / rate : 0.0);
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
packAhd(play.pitchEnv.shape, t0, fullSpan, env);
break;
case OverlayEnv::kFilter:
if (trigger) packAhd(play.filter.trigEnv, t0, playSpan, env);
else packAhdsr(play.filter.env, env);
break;
case OverlayEnv::kAmp:
if (trigger) packAhd(play.trigAhd, t0, playSpan, env);
else packAhdsr(play.adsr, env);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active; a default-constructed StageEnvelope, not amp
}
return env;
}
void ReaSamplerEditor::unpackEnvelope(OverlayEnv which, const StageEnvelope& env,
PlaySeconds& play) const {
const bool trigger = (play.playMode == PlayMode::Trigger);
switch (which) {
case OverlayEnv::kPitch:
unpackAhd(env, play.pitchEnv.shape);
break;
case OverlayEnv::kFilter:
if (trigger) unpackAhd(env, play.filter.trigEnv);
else unpackAhdsr(env, play.filter.env);
break;
case OverlayEnv::kAmp:
if (trigger) unpackAhd(env, play.trigAhd);
else unpackAhdsr(env, play.adsr);
break;
case OverlayEnv::kNone:
break; // nothing is overlay-active, so there is nothing a drag could have edited
}
}
const VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) const {
switch (which) {
case OverlayEnv::kPitch: return params_.play.pitchSpline.contour;
case OverlayEnv::kFilter: return params_.play.filterSpline.contour;
case OverlayEnv::kAmp:
case OverlayEnv::kNone:
break;
}
return params_.play.ampSpline.contour;
}
VelocityCurve& ReaSamplerEditor::splineFor(OverlayEnv which) {
return const_cast<VelocityCurve&>(std::as_const(*this).splineFor(which));
}
bool ReaSamplerEditor::overlayIsSpline() const {
switch (overlayEnv_) {
case OverlayEnv::kAmp: return params_.play.ampSpline.mode == EnvMode::Spline;
case OverlayEnv::kPitch: return params_.play.pitchSpline.mode == EnvMode::Spline;
case OverlayEnv::kFilter: return params_.play.filterSpline.mode == EnvMode::Spline;
case OverlayEnv::kNone: return false;
}
return false;
}
const VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) const {
switch (target) {
case CurveTarget::kPitch: return params_.play.pitchVelocityCurve;
case CurveTarget::kFilter: return params_.play.filter.velocityCurve;
case CurveTarget::kAmp:
case CurveTarget::kNone:
// kNone reads as amp — a valid, harmless choice for a target-agnostic caller (a
// disabled deck cell still needs SOME curve to draw). editedCurve()'s mutable
// overload below refuses kNone rather than relying on this fallback to protect amp.
return params_.velocityCurve;
}
return params_.velocityCurve;
}
VelocityCurve& ReaSamplerEditor::curveFor(CurveTarget target) {
return const_cast<VelocityCurve&>(std::as_const(*this).curveFor(target));
}
const VelocityCurve& ReaSamplerEditor::editedCurve() const { return curveFor(curvePopup_); }
VelocityCurve& ReaSamplerEditor::editedCurve() {
// Refuses kNone rather than aliasing amp (see curvePopup_'s declaration for why); should be
// unreachable now that every writer of kNone also cancels the drag, but this is the second,
// independent line of defense.
if (curvePopup_ == CurveTarget::kNone) {
static VelocityCurve sink = VelocityCurve::flat();
return sink;
}
return curveFor(curvePopup_);
}
} // namespace reasampler::vst
+27 -13
View File
@@ -30,11 +30,14 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled // One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance. // segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
// `seg0Disabled` disables ONE segment: Gate is unselectable while an EG is drawn, but
// Trigger — the mode it is stuck in — must still read as the live choice.
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1, const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
bool seg1Active, bool disabled) { bool seg1Active, bool disabled, bool seg0Disabled = false) {
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id); const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
const bool d0 = disabled || seg0Disabled;
const InteractionState st0 = const InteractionState st0 =
disabled ? InteractionState::Disabled d0 ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active : (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest)); : (hov ? InteractionState::Hover : InteractionState::Rest));
const InteractionState st1 = const InteractionState st1 =
@@ -44,12 +47,13 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0); fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1); fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
kitTextCentered(bmp, t.seg0, s0, Font::Micro, kitTextCentered(bmp, t.seg0, s0, Font::Micro,
disabled ? Role::TextDim d0 ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary)); : (!seg1Active ? Role::BgBase : Role::TextPrimary));
kitTextCentered(bmp, t.seg1, s1, Font::Micro, kitTextCentered(bmp, t.seg1, s1, Font::Micro,
disabled ? Role::TextDim disabled ? Role::TextDim
: (seg1Active ? Role::BgBase : Role::TextPrimary)); : (seg1Active ? Role::BgBase : Role::TextPrimary));
}; };
const bool anySpline = splineActive(play);
// The knob's short name label (swapped for the live value during hover/drag — no third // The knob's short name label (swapped for the live value during hover/drag — no third
// line, no permanent value clutter). // line, no permanent value clutter).
@@ -132,25 +136,36 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
} }
} }
// The compact caption toggle (right-anchored in the caption row, never full-width). // The compact caption toggles (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) { for (const DeckToggleLayout* tp : {&g.captionToggle, &g.captionToggle2}) {
switch (static_cast<ParamControl>(g.captionToggle.id)) { if (tp->id < 0) continue;
const DeckToggleLayout& t = *tp;
switch (static_cast<ParamControl>(t.id)) {
case ParamControl::kPlayMode: case ParamControl::kPlayMode:
drawToggle(g.captionToggle, "Gate", "Trigger", drawToggle(t, "Gate", "Trigger", play.playMode == PlayMode::Trigger, false,
play.playMode == PlayMode::Trigger, false); /*seg0Disabled=*/anySpline);
break; break;
case ParamControl::kPitchEngine: case ParamControl::kPitchEngine:
drawToggle(g.captionToggle, "Varisp", "Presrv", drawToggle(t, "Varisp", "Presrv",
play.pitchEngine == PitchEngine::Preserve, false); play.pitchEngine == PitchEngine::Preserve, false);
break; break;
case ParamControl::kPitchEnvEnable: case ParamControl::kPitchEnvEnable:
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false); drawToggle(t, "Off", "On", play.pitchEnv.enabled, false);
break; break;
case ParamControl::kVoiceMode: case ParamControl::kVoiceMode:
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false); drawToggle(t, "Poly", "Mono", isMono, false);
break; break;
case ParamControl::kFilterEnable: case ParamControl::kFilterEnable:
drawToggle(g.captionToggle, "Off", "On", play.filter.enabled, false); drawToggle(t, "Off", "On", play.filter.enabled, false);
break;
case ParamControl::kAmpEnvMode:
drawToggle(t, "Stg", "Spl", play.ampSpline.mode == EnvMode::Spline, false);
break;
case ParamControl::kPitchEnvMode:
drawToggle(t, "Stg", "Spl", play.pitchSpline.mode == EnvMode::Spline, false);
break;
case ParamControl::kFilterEnvMode:
drawToggle(t, "Stg", "Spl", play.filterSpline.mode == EnvMode::Spline, false);
break; break;
default: break; default: break;
} }
@@ -171,7 +186,6 @@ void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
// The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry. // The knobs. A dependent group's knobs draw Disabled (not hidden) — stable geometry.
// The predicate is the input side's, so the drawn state and the inert grab agree. // The predicate is the input side's, so the drawn state and the inert grab agree.
for (const DeckCellLayout& c : g.cells) { 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); const bool disabled = deckKnobDisabled(c.id);
// A VELOCITY cell is a popup opener, not a dial: it shows its curve in miniature // 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. // where a knob face would be, and its whole cell is the click target.
@@ -13,6 +13,7 @@
#include <vector> #include <vector>
#include "core/audio/peaks.h" // computeEnvelope (waveform binning) #include "core/audio/peaks.h" // computeEnvelope (waveform binning)
#include "core/instrument/ui/spline_edit.h" // splineOverlayBox (the contour's mapping box)
#include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX #include "core/instrument/ui/waveform_view.h" // waveformSurface / laneEnvelope / frameToX
#include "shell/instrument/editor_internal.h" // kit adapters #include "shell/instrument/editor_internal.h" // kit adapters
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
@@ -131,11 +132,72 @@ void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
paintEnvelopeOverlay(bmp, overlay, frames); paintEnvelopeOverlay(bmp, overlay, frames);
} }
void ReaSamplerEditor::paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea) {
const VelocityCurve::Box box = splineOverlayBox(waveArea);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = splineFor(overlayEnv_);
// One eval per drawn column, through the curve's own pixel maps, so the trace and the
// handles share the coordinate system the hit-test resolves against.
const LICE_pixel line = toLice(roleColor(Role::OverlayTrace));
int prevX = 0, prevY = 0;
// < not <=: box.left + box.width is the overlay's own EXCLUSIVE right edge (the box has no
// inset, unlike the popup's), so a <= column paints one pixel into the next band's pad —
// and it is redundant with the clamped endpoint handle below anyway.
for (int px = 0; px < box.width; ++px) {
const int cx = box.left + px;
const double t = curve.pointFromPixel(box, cx, box.top).velocity;
const int cy = curve.pixelFromPoint(box, {t, curve.eval(t)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Handles carry two independent states on the same mark, so they use two independent
// channels: SIZE is the grab (the staged painter's grammar — a hotter hue reads as lower
// contrast over the lime, see its note), and HOLLOW is hard. The corner itself is the
// primary hard cue, but a corner between two near-collinear segments has none to show. A
// grabbed node past the drag-off margin draws WARN — the popup's same "release will
// delete" cue (editor_paint_curve.cpp), since the two spline surfaces share the drag-off
// grammar, not just the click grammar.
const LICE_pixel handle = toLice(roleColor(Role::OverlayTrace));
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
const LICE_pixel core = toLice(roleColor(Role::BgBase));
const bool dragOffArmed = drag_ == DragKind::kSplineNode &&
(dragCurX_ < box.left - kCurveDragOffMargin ||
dragCurX_ > box.left + box.width + kCurveDragOffMargin ||
dragCurY_ < box.top - kCurveDragOffMargin ||
dragCurY_ > box.top + box.height + kCurveDragOffMargin);
const std::vector<instrument::engine::VelocityPoint>& pts = curve.points();
for (std::size_t i = 0; i < pts.size(); ++i) {
const auto np = curve.pixelFromPoint(box, pts[i]);
const bool grabbed =
(drag_ == DragKind::kSplineNode && curvePointIndex_ == static_cast<int>(i));
const int r = grabbed ? kEnvHandleGrabbedRadius : kEnvHandleRadius;
const int ir = r - kEnvHandleRingPx;
const int hx = (std::max)(box.left + r,
(std::min)(box.left + box.width - 1 - r, np.x));
const int hy = (std::max)(box.top + r,
(std::min)(box.top + box.height - 1 - r, np.y));
const LICE_pixel fill = (grabbed && dragOffArmed) ? handleWarn : handle;
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, fill, 1.0f, 0);
if (pts[i].hard && ir > 0) {
LICE_FillRect(bmp, hx - ir, hy - ir, 2 * ir, 2 * ir, core, 1.0f, 0);
}
}
}
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea,
std::int64_t frames) { std::int64_t frames) {
if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state if (overlayEnv_ == OverlayEnv::kNone) return; // no envelope selected is a resting state
const Rect& area = waveArea.rect; const Rect& area = waveArea.rect;
if (frames <= 0 || area.width <= 0 || area.height <= 0) return; if (frames <= 0 || area.width <= 0 || area.height <= 0) return;
if (overlayIsSpline()) {
// The contour is a pure function of normalized position, so it needs neither the frame
// count nor the live rate the staged path below resolves its seconds against.
paintSplineOverlay(bmp, waveArea);
return;
}
const double rate = liveSampleRate(); const double rate = liveSampleRate();
if (rate <= 0.0) return; if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate; const double totalSeconds = static_cast<double>(frames) / rate;
+14 -2
View File
@@ -157,6 +157,14 @@ bool ReaSamplerEditor::dragCommitsLive(DragKind kind, int paramId) const {
return instrument::ui::liveCommitFor(k, paramId); return instrument::ui::liveCommitFor(k, paramId);
} }
void ReaSamplerEditor::closeCurvePopup() {
curvePopup_ = CurveTarget::kNone;
if (drag_ == DragKind::kCurveNode) {
drag_ = DragKind::kNone;
curvePointIndex_ = -1;
}
}
void ReaSamplerEditor::loadSelection(const std::string& id) { void ReaSamplerEditor::loadSelection(const std::string& id) {
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch // A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is // engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
@@ -178,8 +186,12 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's // Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
// override win (the instrument's performance choice). Read the loop intrinsic from the // override win (the instrument's performance choice). Read the loop intrinsic from the
// live bank blob (the same path selectSample uses); when that is not readable (extension // live bank blob (the same path selectSample uses); when that is not readable (extension
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. // absent / not yet parsed) the instance-owned ref carries the same intrinsics. Skipped
if (processor_) { // entirely once an override is already set — it would just be overwritten below, and the
// bridge read + JSON parse it costs is real (mouseDownWaveform's arbitration calls this on
// every waveform click, not just marker grabs, to know whether a tab or marker candidate
// hits at all).
if (processor_ && !params_.loopOverride) {
std::optional<SelectedSample> sel; std::optional<SelectedSample> sel;
auto banksJson = auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
+40 -10
View File
@@ -20,6 +20,7 @@
#include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam) #include "core/instrument/ui/envelope_overlay.h" // StageEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band) #include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator) #include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/spline_edit.h" // the shared point-editing grammar
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior) #include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail) #include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams #include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
@@ -78,8 +79,11 @@ private:
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their // What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored // grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_). // knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
// kSplineNode is the overlay's peer of kCurveNode: the same VelocityCurve point drag, over
// the waveform overlay's box and the overlay-active envelope's contour rather than the
// popup's box and curve.
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode, enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kDeckKnob }; kCurveNode, kSplineNode, kDeckKnob };
// Which envelope the waveform overlay is drawing and editing. The selection type and its // Which envelope the waveform overlay is drawing and editing. The selection type and its
// whole state machine are the pure deck_groups module's; this alias keeps the shell's // whole state machine are the pure deck_groups module's; this alias keeps the shell's
@@ -168,9 +172,20 @@ private:
// The velocity transfer-curve editor (X = velocity 0-127, Y = the curve's own domain); its // 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. // only host is the popup sheet. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r); void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at // Traces the overlay-active envelope + its draggable handles over `waveArea`, ONCE at
// full band height (never per lane). // full band height (never per lane). Dispatches on the envelope's mode: the staged
// polyline, or the drawn contour below.
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames); void paintEnvelopeOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea, std::int64_t frames);
// The spline EG's contour + point handles, spanning the overlay 1:1 with the sample's time
// axis. Hard points draw hollow so a corner is legible before it is steep.
void paintSplineOverlay(LICE_IBitmap* bmp, const OverlayArea& waveArea);
// A click on the spline overlay, routed through the shared grammar; true when it consumed
// the click. `addOnEmptySpace` false resolves node actions only — the waveform band calls
// it that way BEFORE the start/loop markers and again after, since the contour's box is the
// whole band and an unconditional add would make every marker unreachable.
bool splineOverlayClick(const OverlayArea& waveArea, int x, int y,
instrument::ui::SplineGesture gesture, bool addOnEmptySpace);
// --- Input: the mouse-down dispatch and its per-band branches --- // --- Input: the mouse-down dispatch and its per-band branches ---
void onMouseDown(int x, int y); void onMouseDown(int x, int y);
@@ -194,13 +209,14 @@ private:
void onMouseMove(int x, int y); void onMouseMove(int x, int y);
void onMouseUp(int x, int y); void onMouseUp(int x, int y);
// Right-click is the curve popup's primary node-delete affordance; only acts while the // Right-click is the primary node-delete affordance on both spline surfaces — the popup
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op). // while it is open, else the spline EG overlay (deletePoint's endpoint guard makes an
// endpoint right-click a no-op).
void onMouseRDown(int x, int y); void onMouseRDown(int x, int y);
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag; // Mouse-down inside curve-editor box `r`, resolved through the shared point-editing
// Alt-click on an interior node deletes it at once; an empty-space click adds a point // grammar (spline_edit): a node grab starts a kCurveNode drag, an empty-space click adds a
// and grabs it. // point and grabs it, control-click toggles a node hard/smooth.
void handleCurveMouseDown(const Rect& r, int x, int y); void handleCurveMouseDown(const Rect& r, int x, int y);
// Left-click while the curve popup is open (modal over the Sample face): Close / // Left-click while the curve popup is open (modal over the Sample face): Close /
@@ -253,8 +269,8 @@ private:
void commitLive(); void commitLive();
// Whether an in-flight drag commits live rather than through a reload. A deck knob is // Whether an in-flight drag commits live rather than through a reload. A deck knob is
// live per isLiveDeckParam; an envelope-node drag is live only in Gate, where it edits the // live per isLiveDeckParam; an envelope-node drag is live in EITHER mode — see
// AHDSR — in Trigger the same drag rewrites the play span, which is not a live control. // liveCommitFor (deck_groups.h) for why.
bool dragCommitsLive(DragKind kind, int paramId = -1) const; bool dragCommitsLive(DragKind kind, int paramId = -1) const;
// Commits `id` as the loaded capture. The one parameter set carries over — it governs // Commits `id` as the loaded capture. The one parameter set carries over — it governs
@@ -466,6 +482,20 @@ private:
CurveTarget curvePopup_ = CurveTarget::kNone; CurveTarget curvePopup_ = CurveTarget::kNone;
void closeCurvePopup(); void closeCurvePopup();
// The group-gate state the two pure inert predicates read, built once from the parameter
// set so paint and hit-test can never assemble it differently.
instrument::ui::DeckEnableState deckEnableState() const;
// THE one switch from an overlay selection to the drawn contour it names, mirroring
// curveFor. kNone reads as amp — harmless for a target-agnostic caller, and every mutating
// path is gated on overlayIsSpline() first.
const VelocityCurve& splineFor(OverlayEnv which) const;
VelocityCurve& splineFor(OverlayEnv which);
// Whether the overlay-active envelope is in Spline mode — the branch every overlay paint,
// hit-test and drag path takes before touching either model.
bool overlayIsSpline() const;
// THE one switch from a CurveTarget to the parameter-set curve it names — paint (button // THE one switch from a CurveTarget to the parameter-set curve it names — paint (button
// thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both // thumbnails, for every target) and edit (editedCurve, for curvePopup_ specifically) both
// route through it, so a fourth curve or a moved field is a one-place edit. // route through it, so a fourth curve or a moved field is a one-place edit.
+186 -11
View File
@@ -452,7 +452,7 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 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, 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, 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,0x0c,0x00,0x00, 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0d,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,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, 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, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
@@ -515,6 +515,36 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 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,0xc0,0x5f,0x40, // velocity 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // value 0.0
// --- payload v13 dual Staged/Spline state. Three spline EGs (amp, pitch, filter),
// each Staged with the y = 1 - x default contour, then the three velocity curves'
// hard-flag tails, all flags clear ---
0x00, // amp: Staged
0x02,0x00,0x00,0x00, // 2 points
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // x 0.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // y 1.0
0x00, // smooth
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40, // x 127.0
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // y 0.0
0x00, // smooth
0x00, // pitch: Staged
0x02,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
0x00,
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,
0x00, // filter: Staged
0x02,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
0x00,
0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,
0x03,0x00,0x00,0x00, 0x00,0x00,0x00, // amp curve hard flags (3 points)
0x02,0x00,0x00,0x00, 0x00,0x00, // filter curve hard flags
0x02,0x00,0x00,0x00, 0x00,0x00, // pitch curve hard flags
}; };
// clang-format on // clang-format on
CHECK(bytes.size() == sizeof(kGolden)); CHECK(bytes.size() == sizeof(kGolden));
@@ -562,17 +592,18 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono CHECK(bytes[4] == 0); // ChannelMode::Mono
} }
CHECK(kComponentStateVersion == 11); CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 12); CHECK(kParamsPayloadVersion == 13);
CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u); CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter, staged-curve, loop and velocity tails rode PAYLOAD bumps, not envelope ones // The filter, staged-curve, loop, velocity and spline tails rode PAYLOAD bumps, not
// — the two axes stay independent, so a future envelope field cannot collide with any of // envelope ones — the two axes stay independent, so a future envelope field cannot collide
// them on one number. // with any of them on one number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsVelocityVersion > kParamsLoopVersion); CHECK(kParamsVelocityVersion > kParamsLoopVersion);
CHECK(kParamsPayloadVersion == kParamsVelocityVersion); CHECK(kParamsSplineVersion > kParamsVelocityVersion);
CHECK(kParamsPayloadVersion == kParamsSplineVersion);
} }
// --- The filter tail (payload v9) -------------------------------------------- // --- The filter tail (payload v9) --------------------------------------------
@@ -749,6 +780,146 @@ static void testNonFiniteAhdSecondsLiftToZero() {
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0); CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
} }
// --- The v13 hard-flag tail: corruption must never widen past its own three curves -----------
// A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still
// IN-BOUNDS (the blob really does carry that many bytes) — the documented promise
// (component_state_io.h) is that the tail is dropped, never misapplied, and nothing else in
// the record is disturbed. Corrupts only the AMP curve's tail; FILTER/PITCH follow at their
// normal, byte-precise offsets, proving a mismatch on one curve does not cascade to its
// neighbours.
//
// Companion, not a regression guard: this in-bounds-mismatch path was already non-wiping
// before the r.ok fix below — it pins the documented promise, not the fix. The two tests that
// follow (OUT-OF-BOUNDS count, and a mid-count truncation) are what actually guard it — both
// tripped the old "reset the whole record to defaults" behavior.
static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 44;
in.params.keyTrack = 0.6;
in.params.play.adsr.attackSeconds = 0.12;
in.params.play.adsr.sustainLevel = 0.55;
in.params.play.filter.enabled = true;
in.params.play.filter.settings.cutoffNorm = 0.4f;
in.params.play.filter.modAmount = -0.3;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.peakSemitones = 5.0;
in.params.loopCrossfadeFrames = 777;
in.params.play.pitchVelocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, -0.4}, VelocityPoint{127.0, 0.4}},
reasampler::instrument::engine::CurveDomain::Bipolar);
// Every velocity curve left at its DEFAULT 2-point shape, so the v13 hard-flag tail's byte
// layout (three 4-byte-count + N-byte blocks, amp/filter/pitch order — putHardFlags' call
// order in params_payload.cpp) is deterministic and this test can splice it exactly.
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed 4+2-byte blocks
legacy::u32v(bytes, 5); // amp: bogus count...
for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts
legacy::u32v(bytes, 2); // filter: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// Every param preceding AND following the corrupted amp tail survives untouched.
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.keyTrack == 0.6);
CHECK(out.params.play.adsr.attackSeconds == 0.12);
CHECK(out.params.play.adsr.sustainLevel == 0.55);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.play.filter.settings.cutoffNorm == 0.4f);
CHECK(out.params.play.filter.modAmount == -0.3);
CHECK(out.params.play.pitchEnv.enabled);
CHECK(out.params.play.pitchEnv.peakSemitones == 5.0);
CHECK(out.params.loopCrossfadeFrames == 777);
CHECK(out.params.play.pitchVelocityCurve.eval(0.0) == -0.4);
CHECK(out.params.play.pitchVelocityCurve.eval(127.0) == 0.4);
// The mismatched (amp) curve keeps its points; the flags are dropped, never misapplied.
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
CHECK(!out.params.velocityCurve.points()[1].hard);
CHECK(out.params.velocityCurve.equals(reasampler::instrument::engine::VelocityCurve::flat()));
}
// A hard-flag COUNT that exceeds what its OWN tail carries — a genuinely corrupt/out-of-bounds
// count — must be BOUND-AND-SKIPPED without consuming any of the following bytes, so the
// FILTER/PITCH tails immediately after the AMP block still parse at their correct offset. The
// old behavior (r.ok = false) reset the ENTIRE params record to defaults on this path, which is
// strictly worse than the documented "drops only the hard points" promise.
static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 44;
in.params.play.adsr.releaseSeconds = 0.44;
in.params.play.filter.enabled = true;
in.params.play.filter.settings.resonanceNorm = 0.9f;
in.params.loopCrossfadeFrames = 321;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry
// No amp flag bytes follow — bound-and-skip must consume none, so the well-formed
// filter/pitch blocks right after it land exactly where they belong.
legacy::u32v(bytes, 2); // filter: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
legacy::u32v(bytes, 2); // pitch: correct count, unchanged
legacy::u8v(bytes, 0);
legacy::u8v(bytes, 0);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
// The whole record survives — including everything the v13 section itself carries ahead of
// the hard-flag tail (the three spline EGs) and the two well-formed tails after the
// corrupted one — only the AMP curve's hard-flag application is lost.
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 44);
CHECK(out.params.play.adsr.releaseSeconds == 0.44);
CHECK(out.params.play.filter.enabled);
CHECK(out.params.play.filter.settings.resonanceNorm == 0.9f);
CHECK(out.params.loopCrossfadeFrames == 321);
CHECK(out.params.play.ampSpline.mode == EnvMode::Staged);
CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded
CHECK(!out.params.velocityCurve.points()[0].hard);
}
// A hard-flag tail truncated mid-COUNT-FIELD (only 2 of its 4 length bytes present, and
// nothing else after) is a different failure shape than a declared-huge count: the u32 read
// itself fails, tripping r.ok inside readHardFlags rather than its own bound check. That must
// be revived the same way — the whole record survives, only the hard-flag applications (on
// all three curves, since the truncation strands every one of them) are lost.
static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() {
ComponentState in;
in.selectionId = "pad";
in.params.rootOverride = 21;
in.params.play.adsr.decaySeconds = 0.08;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.peakSemitones = -3.0;
in.params.loopCrossfadeFrames = 5;
std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() >= 18);
bytes.resize(bytes.size() - 18); // drop the three well-formed hard-flag blocks
legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing
legacy::u8v(bytes, 0x00);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
CHECK(out.selectionId == "pad");
CHECK(out.params.rootOverride && *out.params.rootOverride == 21);
CHECK(out.params.play.adsr.decaySeconds == 0.08);
CHECK(out.params.play.pitchEnv.enabled);
CHECK(out.params.play.pitchEnv.peakSemitones == -3.0);
CHECK(out.params.loopCrossfadeFrames == 5);
CHECK(out.params.velocityCurve.size() == 2);
CHECK(!out.params.velocityCurve.points()[0].hard);
}
// --- The loop tail (payload v11) --------------------------------------------- // --- The loop tail (payload v11) ---------------------------------------------
// The loop span and its crossfade survive a save/reload intact, alongside the two overrides // The loop span and its crossfade survive a save/reload intact, alongside the two overrides
@@ -1460,11 +1631,12 @@ static void testSampleRefsTruncatedMidEntry() {
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the // 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 // 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default) + the 134-byte
// v9 filter tail + the 152-byte v10 staged-curve tail + the 8-byte v11 crossfade + the // 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, // 36-byte v12 pitch curve + the 135-byte v13 dual-state tail, three 39-byte spline EGs and
// loop 1+8+8, channels4, name 4+0). Cutting 508 keeps the first 27 of entry two's 47 // three 6-byte hard-flag tails) = 623 bytes; entry two is 47 bytes (id 4+3, path 4+7,
// mid loop.start (offset 23..31). // root4, loop 1+8+8, channels4, name 4+0). Cutting 643 keeps the first 27 of entry two's
CHECK(bytes.size() > 508); // 47 — mid loop.start (offset 23..31).
bytes.resize(bytes.size() - 508); CHECK(bytes.size() > 643);
bytes.resize(bytes.size() - 643);
const ComponentState back = deserializeComponentState(bytes, 44100.0); const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1); CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick"); CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
@@ -1573,6 +1745,9 @@ int main() {
testPitchVelocityCurveRoundTripsIndependently(); testPitchVelocityCurveRoundTripsIndependently();
testNonFiniteFilterFieldsLiftToTheNeutralDefault(); testNonFiniteFilterFieldsLiftToTheNeutralDefault();
testNonFiniteAhdSecondsLiftToZero(); testNonFiniteAhdSecondsLiftToZero();
testV13HardFlagInBoundsMismatchDropsFlagsOnly();
testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord();
testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord();
if (failures == 0) { if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n"); std::printf("component_state_io_tests: all tests passed\n");
return 0; return 0;
+191 -18
View File
@@ -3,8 +3,9 @@
// descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp), // descriptors the Sample face carries: the signal-flow group order (pitch -> filter -> amp),
// the Filter group's contents, the VELOCITY group's exclusive ownership of the three curve // 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 // cells and its placement immediately left of VOICE, the wrapped deck height at the editor's
// floor width and its fit // floor width and its fit inside the floor window, the pinned Gate widths and row assignment,
// inside the floor window, the hit-test reaching the new filter controls, the bipolar knob // that no face leaves slack where its dropped controls were and that a Gate/Spline/Gate round
// trip restores the layout exactly, 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 // 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, // the live tier — and the overlay-selection state machine (exclusivity, the none resting state,
// and which selections are inert). // and which selections are inert).
@@ -89,7 +90,7 @@ static void testCurveTargetNamesEachCellsOwnDestination() {
CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter); CHECK(curveTargetFor(cell(DeckParam::kFilterVelCurve)) == CurveTarget::kFilter);
CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kFilterCutoff)) == CurveTarget::kNone);
CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone); CHECK(curveTargetFor(cell(DeckParam::kMasterGain)) == CurveTarget::kNone);
CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a blank reserved cell CHECK(curveTargetFor(-1) == CurveTarget::kNone); // a width reserve, not a control
CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space CHECK(curveTargetFor(9999) == CurveTarget::kNone); // out of the id space
} }
@@ -264,6 +265,7 @@ static void testDeckFitsInsideTheEnforcedMinimumWindow() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode); const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const int h = deckHeight(g, kAvailAtMinWidth); const int h = deckHeight(g, kAvailAtMinWidth);
const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h); const SampleBands b = computeSampleBands(kEditorMinWidth, kEditorMinHeight, h);
CHECK(deckRowCount(g, kAvailAtMinWidth) == 3); // either face, three rows at the floor
CHECK(b.decks.height == h); CHECK(b.decks.height == h);
// Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the // Bottom-anchored INSIDE the pad is the whole assertion: the degrade path pushes the
// deck down until the waveform hits its floor, so any deck too tall to fit stops // deck down until the waveform hits its floor, so any deck too tall to fit stops
@@ -366,6 +368,7 @@ static void testEveryDeckControlIsClassifiedLiveOrReloading() {
DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve, DeckParam::kAmpVelCurve, DeckParam::kPitchVelCurve, DeckParam::kFilterVelCurve,
DeckParam::kKeyTrack, DeckParam::kTrigLength, DeckParam::kKeyTrack, DeckParam::kTrigLength,
DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect, DeckParam::kAmpEnvSelect, DeckParam::kPitchEnvSelect, DeckParam::kFilterEnvSelect,
DeckParam::kAmpEnvMode, DeckParam::kPitchEnvMode, DeckParam::kFilterEnvMode,
DeckParam::kVoiceCount, DeckParam::kVoiceMode, DeckParam::kVoiceCount, DeckParam::kVoiceMode,
DeckParam::kMonoTrigger, DeckParam::kMasterGain, DeckParam::kMonoTrigger, DeckParam::kMasterGain,
}; };
@@ -450,16 +453,36 @@ static void testANonRadioIdLeavesTheOverlaySelectionAlone() {
CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp); CHECK(nextOverlaySelection(OverlayEnv::kAmp, 9999) == OverlayEnv::kAmp);
} }
// The two group gates, spelled the way the predicates read them. Spline flags default off, so
// a case that says nothing about them is asserting the staged behaviour.
static DeckEnableState gates(bool pitchEnv, bool filter) {
DeckEnableState s;
s.pitchEnvEnabled = pitchEnv;
s.filterEnabled = filter;
return s;
}
// An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on // An overlay whose deck group is switched OFF is inert, matching the drawn-but-dead knobs on
// the same params: a node drag must not reach a value the knob refuses. // the same params: a node drag must not reach a value the knob refuses.
static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() { static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
CHECK(overlayEnvInert(OverlayEnv::kPitch, /*pitchEnv=*/false, /*filter=*/true)); CHECK(overlayEnvInert(OverlayEnv::kPitch, gates(/*pitchEnv=*/false, /*filter=*/true)));
CHECK(!overlayEnvInert(OverlayEnv::kPitch, true, true)); CHECK(!overlayEnvInert(OverlayEnv::kPitch, gates(true, true)));
CHECK(overlayEnvInert(OverlayEnv::kFilter, true, /*filter=*/false)); CHECK(overlayEnvInert(OverlayEnv::kFilter, gates(true, /*filter=*/false)));
CHECK(!overlayEnvInert(OverlayEnv::kFilter, true, true)); CHECK(!overlayEnvInert(OverlayEnv::kFilter, gates(true, true)));
// Amp has no enable toggle, so it is never inert; kNone draws nothing to grab. // Amp has no enable toggle, so it is never inert; kNone draws nothing to grab.
CHECK(!overlayEnvInert(OverlayEnv::kAmp, false, false)); CHECK(!overlayEnvInert(OverlayEnv::kAmp, gates(false, false)));
CHECK(!overlayEnvInert(OverlayEnv::kNone, false, false)); CHECK(!overlayEnvInert(OverlayEnv::kNone, gates(false, false)));
// The enable gate alone, which the SPLINE overlay reads: it survives a mode switch, so a
// disabled group's contour is as dead as its knobs.
CHECK(!overlayEnvEnabled(OverlayEnv::kPitch, gates(false, true)));
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, gates(false, false)));
// ...while the staged overlay additionally goes inert once the envelope is drawn: its
// nodes are no longer what the overlay is editing.
DeckEnableState drawn = gates(true, true);
drawn.ampSpline = true;
CHECK(overlayEnvInert(OverlayEnv::kAmp, drawn));
CHECK(overlayEnvEnabled(OverlayEnv::kAmp, drawn));
} }
// A deck knob goes inert exactly with its group's own enable toggle — including the filter's // A deck knob goes inert exactly with its group's own enable toggle — including the filter's
@@ -467,16 +490,161 @@ static void testOverlayIsInertExactlyWhenItsGroupToggleIsOff() {
// go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck // go inert with the rest of the filter (the reachable-through-the-deck route mouseDownDeck
// checks before ever routing a curve-cell click to the popup). // checks before ever routing a curve-cell click to the popup).
static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() { static void testDeckKnobIsInertExactlyWithItsGroupsEnableToggle() {
CHECK(deckKnobInert(DeckParam::kFilterVelCurve, /*pitchEnv=*/true, /*filter=*/false)); CHECK(deckKnobInert(DeckParam::kFilterVelCurve, gates(/*pitchEnv=*/true, /*filter=*/false)));
CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, true, true)); CHECK(!deckKnobInert(DeckParam::kFilterVelCurve, gates(true, true)));
CHECK(deckKnobInert(DeckParam::kFilterCutoff, true, false)); CHECK(deckKnobInert(DeckParam::kFilterCutoff, gates(true, false)));
CHECK(!deckKnobInert(DeckParam::kFilterCutoff, true, true)); CHECK(!deckKnobInert(DeckParam::kFilterCutoff, gates(true, true)));
CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, /*pitchEnv=*/false, true)); CHECK(deckKnobInert(DeckParam::kPitchEnvDepth, gates(/*pitchEnv=*/false, true)));
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, true, true)); CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates(true, true)));
// The amp's own velocity cell and every ordinary control are never inert here — inertness // The amp's own velocity cell and every ordinary control are never inert here — inertness
// is a filter/pitch-env-group-only concept. // is a filter/pitch-env-group-only concept until an envelope is drawn.
CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, false, false)); CHECK(!deckKnobInert(DeckParam::kAmpVelCurve, gates(false, false)));
CHECK(!deckKnobInert(DeckParam::kAttack, false, false)); CHECK(!deckKnobInert(DeckParam::kAttack, gates(false, false)));
}
// A drawn envelope's STAGED segment knobs go inert; the mode toggle itself and the depth knobs
// that scale either shape stay live. (Which segment knobs, per envelope, is pinned in
// spline_egs_tests alongside the rest of the spline rules.)
static void testAModeToggleIsNeitherLiveNorAnOverlayRadio() {
CHECK(!isLiveDeckParam(DeckParam::kAmpEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kPitchEnvMode));
CHECK(!isLiveDeckParam(DeckParam::kFilterEnvMode));
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kAmp);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kPitchEnvMode)) == OverlayEnv::kPitch);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kFilterEnvMode)) == OverlayEnv::kFilter);
// A mode toggle must not be mistaken for the overlay-select radio beside it.
CHECK(overlayEnvForRadio(radio(DeckParam::kAmpEnvMode)) == OverlayEnv::kNone);
CHECK(overlayEnvForModeToggle(radio(DeckParam::kAmpEnvSelect)) == OverlayEnv::kNone);
}
// The three mode toggles ride each env group's caption slack, so the deck's wrapped geometry
// is unchanged by them: raising their segment width past the caption headroom would reflow the
// first row and push the deck to a fourth one (see testDeckFitsInsideTheEnforcedMinimumWindow).
static void testTheModeTogglesCostNoGroupWidth() {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
for (const DeckGroupDesc& g : sampleDeckGroups(mode)) {
if (g.captionToggle2.id < 0) continue;
DeckGroupDesc without = g;
without.captionToggle2 = DeckToggleDesc{};
CHECK(deckGroupWidth(g) == deckGroupWidth(without));
}
}
}
// A typical larger window, to check the same properties once the deck has re-wrapped.
static constexpr int kAvailAtLargerWidth = 1100 - 2 * kPad;
// The gap fix as a property of the shipped descriptors, not a picture: whichever face a
// mode-dependent group shows, its knob row still spans the group's whole reserved run. The
// Trigger faces drop Sustain and Release and get wider cells for it — never a hole where the
// dropped control was. What the run does not cover is the indivisible residue alone, strictly
// under one pixel per cell.
static void testNoFaceLeavesSlackWhereItsDroppedControlsWere() {
for (int avail : {kAvailAtMinWidth, kAvailAtLargerWidth}) {
for (PlayMode mode : {PlayMode::Gate, PlayMode::Trigger}) {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(mode);
const DeckLayout dl = layoutDeck(g, kPad, 0, avail);
CHECK(dl.groups.size() == g.size());
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
const DeckGroupLayout& lay = dl.groups[i];
const int reserved = static_cast<int>(g[i].cellIds.size()) * kDeckCellW;
const std::size_t present = lay.cells.size();
CHECK(present > 0);
for (std::size_t k = 0; k < present; ++k) {
const DeckCellLayout& c = lay.cells[k];
CHECK(c.id >= 0); // a reserve yields width, never a dead rect
CHECK(c.cell.width == lay.cells[0].cell.width);
if (k > 0) CHECK(c.cell.x == lay.cells[k - 1].cell.right());
}
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(reserved - covered < static_cast<int>(present));
CHECK(lay.cells.front().cell.x >= lay.box.x + kDeckGroupPadX);
CHECK(lay.cells.back().cell.right() <= lay.box.right() - kDeckGroupPadX);
}
}
}
}
// The "residue lands in symmetric end margins" rule is knob_deck's own (layoutGroup), pinned
// once by its synthetic residue>=2 fixture in test_knob_deck.cpp rather than restated here.
// Gate is the common face and it already packs correctly: pin its group widths and row
// assignment at the floor so a later edit anywhere in the deck cannot reflow it silently.
// (Measured from the shipped descriptors, not copied out of a failing run.)
static void testGateModeWidthsAndRowAssignmentAreUnchanged() {
const std::vector<DeckGroupDesc> g = sampleDeckGroups(PlayMode::Gate);
const struct { int id; int width; int row; } want[] = {
{kGroupPitch, 150, 0}, {kGroupPitchEnv, 204, 0}, {kGroupFilter, 440, 0},
{kGroupFilterEnv, 252, 1}, {kGroupAmpEnv, 252, 1}, {kGroupVelocity, 156, 1},
{kGroupVoice, 152, 2}, {kGroupMaster, 60, 2},
};
CHECK(g.size() == sizeof(want) / sizeof(want[0]));
const DeckLayout dl = layoutDeck(g, kPad, 0, kAvailAtMinWidth);
for (std::size_t i = 0; i < dl.groups.size(); ++i) {
CHECK(dl.groups[i].id == want[i].id);
CHECK(deckGroupWidth(g[i]) == want[i].width);
CHECK(dl.groups[i].box.width == want[i].width);
CHECK(dl.groups[i].box.y == want[i].row * (kDeckGroupH + kDeckRowGap));
// Gate carries no reserves, so its cells are the deck's base size.
for (const DeckCellLayout& c : dl.groups[i].cells) CHECK(c.cell.width == kDeckCellW);
}
}
static bool sameToggle(const DeckToggleLayout& a, const DeckToggleLayout& b) {
return a.id == b.id && a.seg0 == b.seg0 && a.seg1 == b.seg1;
}
static bool sameLayout(const DeckLayout& a, const DeckLayout& b) {
if (a.rowCount != b.rowCount || a.height != b.height ||
a.groups.size() != b.groups.size()) return false;
for (std::size_t i = 0; i < a.groups.size(); ++i) {
const DeckGroupLayout& x = a.groups[i];
const DeckGroupLayout& y = b.groups[i];
if (x.id != y.id || !(x.box == y.box) || !(x.caption == y.caption)) return false;
if (x.captionRadio.id != y.captionRadio.id || !(x.captionRadio.box == y.captionRadio.box))
return false;
if (!sameToggle(x.captionToggle, y.captionToggle) ||
!sameToggle(x.captionToggle2, y.captionToggle2) ||
!sameToggle(x.rowToggle, y.rowToggle)) return false;
if (x.cells.size() != y.cells.size()) return false;
for (std::size_t k = 0; k < x.cells.size(); ++k) {
const DeckCellLayout& c = x.cells[k];
const DeckCellLayout& d = y.cells[k];
if (c.id != d.id || !(c.cell == d.cell) || !(c.knob == d.knob) ||
!(c.inner == d.inner) || !(c.label == d.label)) return false;
}
}
return true;
}
// A Spline excursion is fully reversible at the layout level: the mode forcing swaps the amp
// and filter faces onto their wider cells and back, leaving no residue in the geometry. Driven
// through the shared enforceGateUnavailableWhileDrawn helper, so the deck cannot agree with a
// forcing rule the real callers do not use.
static void testGateSplineGateRoundTripsToTheSameLayout() {
PlayParams p; // Gate, all three envelopes staged
const DeckLayout before = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
p.ampSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(p); // the shared helper both real callers route through
CHECK(p.playMode == PlayMode::Trigger);
const DeckLayout drawn = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
// The excursion is real: the amp face's cells are strictly wider than Gate's.
const DeckGroupLayout& gateAmp =
before.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Gate),
kGroupAmpEnv))];
const DeckGroupLayout& trigAmp =
drawn.groups[static_cast<std::size_t>(indexOfGroup(sampleDeckGroups(PlayMode::Trigger),
kGroupAmpEnv))];
CHECK(trigAmp.cells.size() < gateAmp.cells.size());
CHECK(trigAmp.cells[0].cell.width > gateAmp.cells[0].cell.width);
CHECK(!sameLayout(before, drawn));
p.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(p));
p.playMode = PlayMode::Gate; // Gate is selectable again once nothing is drawn
const DeckLayout after = layoutDeck(sampleDeckGroups(p.playMode), kPad, 0, kAvailAtMinWidth);
CHECK(sameLayout(before, after));
} }
int main() { int main() {
@@ -485,6 +653,8 @@ int main() {
testANonRadioIdLeavesTheOverlaySelectionAlone(); testANonRadioIdLeavesTheOverlaySelectionAlone();
testOverlayIsInertExactlyWhenItsGroupToggleIsOff(); testOverlayIsInertExactlyWhenItsGroupToggleIsOff();
testDeckKnobIsInertExactlyWithItsGroupsEnableToggle(); testDeckKnobIsInertExactlyWithItsGroupsEnableToggle();
testAModeToggleIsNeitherLiveNorAnOverlayRadio();
testTheModeTogglesCostNoGroupWidth();
testEveryDeckControlIsClassifiedLiveOrReloading(); testEveryDeckControlIsClassifiedLiveOrReloading();
testOnlyALiveControlsDragTakesTheLiveTier(); testOnlyALiveControlsDragTakesTheLiveTier();
testDeckReadsPitchThenFilterThenAmpLeftToRight(); testDeckReadsPitchThenFilterThenAmpLeftToRight();
@@ -498,6 +668,9 @@ int main() {
testAmpGroupWidthSurvivesAGateTriggerFlip(); testAmpGroupWidthSurvivesAGateTriggerFlip();
testWrappedDeckHeightAtTheEditorFloorWidth(); testWrappedDeckHeightAtTheEditorFloorWidth();
testDeckFitsInsideTheEnforcedMinimumWindow(); testDeckFitsInsideTheEnforcedMinimumWindow();
testNoFaceLeavesSlackWhereItsDroppedControlsWere();
testGateModeWidthsAndRowAssignmentAreUnchanged();
testGateSplineGateRoundTripsToTheSameLayout();
testHitTestResolvesTheNewFilterControls(); testHitTestResolvesTheNewFilterControls();
testBipolarKnobLawRoundTripsAndIsExactAtCentre(); testBipolarKnobLawRoundTripsAndIsExactAtCentre();
if (g_fail == 0) std::printf("deck_groups: all tests passed\n"); if (g_fail == 0) std::printf("deck_groups: all tests passed\n");
+149 -20
View File
@@ -2,15 +2,17 @@
// assert loop as the sibling pure tests. Assert the r11 deck layout HARD: // assert loop as the sibling pure tests. Assert the r11 deck layout HARD:
// //
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths. // * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right // * layout — caption toggle right-anchored IN the caption row; cells abutting left-to-right
// inside the box; knob square centered; label band beneath; row toggle after the cells. // inside the box; knob square centered; label band beneath; row toggle after the cells.
// * reserves — a -1 id holds the group's width and hands its pixels to the cells present.
// * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row // * wrap — deterministic whole-group wrap at a narrowing width; the first group of a row
// always places; deckHeight consistency with deckRowCount. // always places; deckHeight consistency with deckRowCount.
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells // * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, fence padding
// and fence padding miss, outside-deck miss. // misses, outside-deck misses.
#include "../src/core/instrument/ui/knob_deck.h" #include "../src/core/instrument/ui/knob_deck.h"
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -26,27 +28,27 @@ static int g_fail = 0;
// toggle + row toggle), MASTER (1 cell, no toggle). // toggle + row toggle), MASTER (1 cell, no toggle).
static std::vector<DeckGroupDesc> shellLikeDeck() { static std::vector<DeckGroupDesc> shellLikeDeck() {
std::vector<DeckGroupDesc> g; std::vector<DeckGroupDesc> g;
g.push_back({0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}}); g.push_back({0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}});
g.push_back({1, 38, {}, {101, 48}, {6}, {}}); g.push_back({1, 38, {}, {101, 48}, {}, {6}, {}});
g.push_back({2, 58, {}, {102, 32}, {7, 8, 9}, {}}); g.push_back({2, 58, {}, {102, 32}, {}, {7, 8, 9}, {}});
g.push_back({3, 38, {}, {103, 40}, {10}, {104, 44}}); g.push_back({3, 38, {}, {103, 40}, {}, {10}, {104, 44}});
g.push_back({4, 46, {}, {}, {11}, {}}); g.push_back({4, 46, {}, {}, {}, {11}, {}});
return g; return g;
} }
static void testGroupWidth() { static void testGroupWidth() {
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6. // Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
DeckGroupDesc amp{0, 78, {}, {100, 44}, {1, 2, 3, 4, 5}, {}}; DeckGroupDesc amp{0, 78, {}, {100, 44}, {}, {1, 2, 3, 4, 5}, {}};
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX); CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12. // Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
DeckGroupDesc pitch{1, 38, {}, {101, 48}, {6}, {}}; DeckGroupDesc pitch{1, 38, {}, {101, 48}, {}, {6}, {}};
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX); CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122. // Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
DeckGroupDesc voice{3, 38, {}, {103, 40}, {10}, {104, 44}}; DeckGroupDesc voice{3, 38, {}, {103, 40}, {}, {10}, {104, 44}};
CHECK(deckGroupWidth(voice) == CHECK(deckGroupWidth(voice) ==
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX); kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
// No toggles: max(caption, cells) + padding. // No toggles: max(caption, cells) + padding.
DeckGroupDesc master{4, 46, {}, {}, {11}, {}}; DeckGroupDesc master{4, 46, {}, {}, {}, {11}, {}};
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX); CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
} }
@@ -146,14 +148,27 @@ static void testHitTest() {
h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1); h = hitTestDeck(dl, voice.rowToggle.seg1.x + 1, voice.rowToggle.seg1.y + 1);
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1); CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
// A blank cell (id -1) misses even though its rect exists. // A reserve (id -1) yields no cell of its own. This fixture's reserve divides its present
// cells evenly (5 slots / 3 present -> 240/3, no residue), so every point of the knob row
// lands on a real control: no dead rect survives for a grab to fall into. That does NOT
// generalize to an indivisible reserve — a residue leaves a few uncovered margin pixels by
// design (testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds, below).
std::vector<DeckGroupDesc> trig; std::vector<DeckGroupDesc> trig;
trig.push_back({0, 78, {}, {100, 44}, {20, 21, 22, -1, -1}, {}}); trig.push_back({0, 78, {}, {100, 44}, {}, {20, 21, 22, -1, -1}, {}});
const DeckLayout tl = layoutDeck(trig, 0, 0, 824); const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
const DeckCellLayout& blank = tl.groups[0].cells[4]; const DeckGroupLayout& tg = tl.groups[0];
CHECK(blank.id == -1); CHECK(tg.cells.size() == 3);
h = hitTestDeck(tl, blank.cell.x + 5, blank.cell.y + 5); for (const DeckCellLayout& c : tg.cells) CHECK(c.id >= 0);
CHECK(h.kind == DeckHitKind::None); // Bound the sweep against the RESERVED run (5 slots, not the 3 present cells) rather than
// the cells' own extent — the cells are what's under test, so deriving the bound from them
// could never catch a layout that under-covers the run they were reserved out of.
const int runStart = tg.box.x + kDeckGroupPadX;
const int runEnd = runStart + static_cast<int>(trig[0].cellIds.size()) * kDeckCellW;
const int rowY = tg.cells.back().cell.y + 5;
for (int px = runStart; px < runEnd; ++px) {
const DeckHit rowHit = hitTestDeck(tl, px, rowY);
CHECK(rowHit.kind == DeckHitKind::Knob && rowHit.id >= 0);
}
// The fence padding inside the box misses; outside the deck misses. // The fence padding inside the box misses; outside the deck misses.
h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1); h = hitTestDeck(dl, amp.box.x + 1, amp.box.bottom() - 1);
@@ -162,11 +177,87 @@ static void testHitTest() {
CHECK(h.kind == DeckHitKind::None); CHECK(h.kind == DeckHitKind::None);
} }
// A reserve holds the group's WIDTH and hands its pixels to the cells that are present. The
// three properties together are what stops a narrower face reading as a hole: the group is
// exactly as wide as the full-face one, the cells are uniform and abutting, and what they do
// not cover is smaller than one pixel per cell.
static void testReservedCellWidthGoesToTheCellsPresent() {
const DeckGroupDesc full{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24}, {}};
// Three, four, and a lone cell against the same five-slot reserve — 240/3, 240/4, 240/1.
const std::vector<std::vector<int>> faces = {
{20, 21, 22, -1, -1}, {20, 21, 22, 23, -1}, {20, -1, -1, -1, -1}};
for (const std::vector<int>& ids : faces) {
DeckGroupDesc narrow = full;
narrow.cellIds = ids;
CHECK(deckGroupWidth(narrow) == deckGroupWidth(full));
std::vector<DeckGroupDesc> g{narrow};
const DeckLayout dl = layoutDeck(g, 0, 0, 824);
const DeckGroupLayout& lay = dl.groups[0];
const int present = static_cast<int>(lay.cells.size());
CHECK(present == 5 - static_cast<int>(std::count(ids.begin(), ids.end(), -1)));
const int run = 5 * kDeckCellW;
for (int i = 0; i < present; ++i) {
const DeckCellLayout& c = lay.cells[static_cast<std::size_t>(i)];
CHECK(c.cell.width == lay.cells[0].cell.width); // uniform
CHECK(c.knob.width == kDeckKnobSize); // the dial itself is fixed
CHECK(c.knob.x - c.cell.x == c.cell.right() - c.knob.right());
if (i > 0) CHECK(c.cell.x == lay.cells[static_cast<std::size_t>(i - 1)].cell.right());
}
// Uncovered run is the indivisible residue only, split evenly at the two ends.
const int covered = lay.cells.back().cell.right() - lay.cells[0].cell.x;
CHECK(run - covered < present);
const int leadPad = lay.cells[0].cell.x - (lay.box.x + kDeckGroupPadX);
CHECK(leadPad == (run - covered) / 2);
}
// A reserve does not move the row toggle: it anchors past the whole run, so the FILTER
// group's law switch cannot drift when a neighbouring face changes shape.
DeckGroupDesc withToggle{1, 40, {}, {}, {}, {20, 21, 22, 23, 24}, {104, 44}};
std::vector<DeckGroupDesc> a{withToggle};
withToggle.cellIds = {20, 21, -1, -1, -1};
std::vector<DeckGroupDesc> b{withToggle};
CHECK(layoutDeck(a, 0, 0, 824).groups[0].rowToggle.seg0 ==
layoutDeck(b, 0, 0, 824).groups[0].rowToggle.seg0);
}
// The three faces above (240/3, 240/4, 240/1) all divide their run evenly, so none of them
// actually exercises "residue in symmetric end margins". An 8-slot reserve with 5 present
// (384/5 = 76 r4) does: residue 4 is the smallest case that can tell a symmetric split (2/2)
// apart from a trailing-only one (0/4) — a residue of 1 (0/1 vs 1/0... i.e. 0/1) can't, since
// leadPad = residue/2 rounds to 0 either way, which is exactly why this seam's earlier test
// passed without pinning the rule it was named for.
static void testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds() {
const DeckGroupDesc g{0, 78, {}, {100, 44}, {}, {20, 21, 22, 23, 24, -1, -1, -1}, {}};
std::vector<DeckGroupDesc> gs{g};
const DeckLayout dl = layoutDeck(gs, 0, 0, 824);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.cells.size() == 5);
const int run = 8 * kDeckCellW;
const int present = 5;
const int cellW = run / present; // 76: the same integer division the layout uses
const int expectedResidue = run - cellW * present; // 4
CHECK(expectedResidue == 4);
const int covered = lay.cells.back().cell.right() - lay.cells.front().cell.x;
CHECK(run - covered == expectedResidue);
const int leadPad = lay.cells.front().cell.x - (lay.box.x + kDeckGroupPadX);
const int trailPad = (lay.box.right() - kDeckGroupPadX) - lay.cells.back().cell.right();
// Hard literals, not just the formula: this is the case that actually distinguishes
// symmetric (2/2) from trailing-only (0/4) — see the comment above.
CHECK(leadPad == 2);
CHECK(trailPad == 2);
CHECK(leadPad == expectedResidue / 2);
CHECK(trailPad == expectedResidue - leadPad); // both ends share it, not one absorbing it
}
// The corner radio widens the caption row, takes the far corner, and pushes the caption // The corner radio widens the caption row, takes the far corner, and pushes the caption
// toggle left of itself — the three properties the overlay-select switch relies on. // toggle left of itself — the three properties the overlay-select switch relies on.
static void testCaptionRadioGeometryAndHit() { static void testCaptionRadioGeometryAndHit() {
const DeckGroupDesc bare{7, 78, {}, {200, 44}, {1, 2}, {}}; const DeckGroupDesc bare{7, 78, {}, {200, 44}, {}, {1, 2}, {}};
const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {1, 2}, {}}; const DeckGroupDesc withRadio{7, 78, {201}, {200, 44}, {}, {1, 2}, {}};
// Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose // Caption row grows by exactly gap + radio; the knob row is unchanged, so a group whose
// caption row already dominated grows by that much. // caption row already dominated grows by that much.
CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) == CHECK(deckGroupWidth(withRadio) - deckGroupWidth(bare) ==
@@ -205,6 +296,41 @@ static void testInnerDialHit() {
CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && !h.inner); CHECK(h.kind == DeckHitKind::Knob && h.id == c.id && !h.inner);
} }
// captionToggle2 sits immediately left of captionToggle when both are present (no overlap, and
// the caption text stops before the LEFTMOST one), and takes captionToggle's own slot when
// captionToggle is absent — the shipped FILTER ENV group's exact shape (deck_groups.cpp).
static void testCaptionToggle2() {
const DeckGroupDesc both{9, 40, {}, {300, 30}, {301, 20}, {1, 2, 3}, {}};
std::vector<DeckGroupDesc> g{both};
const DeckLayout dl = layoutDeck(g, 0, 0, 800);
const DeckGroupLayout& lay = dl.groups[0];
CHECK(lay.captionToggle.id == 300);
CHECK(lay.captionToggle2.id == 301);
CHECK(lay.captionToggle2.seg0.width == 20 && lay.captionToggle2.seg1.width == 20);
// Left of the first, with exactly one gap between — no overlap by construction.
CHECK(lay.captionToggle2.seg1.right() == lay.captionToggle.seg0.x - kDeckToggleGap);
// Caption text stops before the LEFTMOST toggle (toggle2), not just the first-placed one.
CHECK(lay.caption.right() <= lay.captionToggle2.seg0.x);
DeckHit h = hitTestDeck(dl, lay.captionToggle2.seg0.right() - 1,
lay.captionToggle2.seg0.y + 1);
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 301 && h.segment == 0);
h = hitTestDeck(dl, lay.captionToggle2.seg1.x, lay.captionToggle2.seg1.y + 1);
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 301 && h.segment == 1);
// FILTER ENV's real shape: captionToggle absent, captionToggle2 present with a radio — it
// takes the first (rightmost) slot rather than leaving a gap where captionToggle would sit.
const DeckGroupDesc filterEnvLike{10, 66, {200}, {}, {302, 23}, {1, 2, 3, 4, 5}, {}};
std::vector<DeckGroupDesc> g2{filterEnvLike};
const DeckLayout dl2 = layoutDeck(g2, 0, 0, 800);
const DeckGroupLayout& fe = dl2.groups[0];
CHECK(fe.captionToggle.id == -1);
CHECK(fe.captionToggle2.id == 302);
CHECK(fe.captionToggle2.seg1.right() == fe.captionRadio.box.x - kDeckToggleGap);
h = hitTestDeck(dl2, fe.captionToggle2.seg1.x, fe.captionToggle2.seg1.y + 1);
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 302 && h.segment == 1);
}
static void testEmptyDeck() { static void testEmptyDeck() {
const std::vector<DeckGroupDesc> none; const std::vector<DeckGroupDesc> none;
CHECK(deckRowCount(none, 800) == 0); CHECK(deckRowCount(none, 800) == 0);
@@ -219,8 +345,11 @@ int main() {
testFirstGroupAlwaysPlaces(); testFirstGroupAlwaysPlaces();
testGroupInnerGeometry(); testGroupInnerGeometry();
testHitTest(); testHitTest();
testReservedCellWidthGoesToTheCellsPresent();
testIndivisibleResidueSplitsSymmetricallyAcrossBothEnds();
testCaptionRadioGeometryAndHit(); testCaptionRadioGeometryAndHit();
testInnerDialHit(); testInnerDialHit();
testCaptionToggle2();
testEmptyDeck(); testEmptyDeck();
if (g_fail) { if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail); std::printf("%d FAILURE(S)\n", g_fail);
+279
View File
@@ -0,0 +1,279 @@
// Standalone tests for reasampler::instrument::ui::spline_edit — no VST3, no REAPER, no
// framework. Asserts the ONE point-editing grammar both spline consumers route through:
// left-click grabs a node and adds in empty space, right-click deletes, control-click toggles
// hard/smooth, and a click outside the mapping box resolves to nothing unless it lands on a
// node's pick radius (so the popup's inset ring can grab but never add). Also
// resolveWaveformClaim, the waveform overlay's node/tab/marker cross-affordance arbitration —
// waveform_view is a test-only link so those tests can build the real geometry
// editor_input_waveform.cpp's mouseDownWaveform composes.
#include "../src/core/instrument/ui/spline_edit.h"
#include "../src/core/instrument/ui/waveform_view.h"
#include "../src/core/instrument/ui/sample_bands.h" // kWaveformMinHeight
#include <cstdio>
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::engine;
static OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; }
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static const VelocityCurve::Box kBox{100, 50, 127, 101};
// A three-point curve whose interior node sits well away from both endpoints.
static VelocityCurve threePoint() {
VelocityCurve c = VelocityCurve::rampDown();
c.addPoint(64.0, 0.5);
return c;
}
static void testLeftClickOnANodeGrabsIt() {
const VelocityCurve c = threePoint();
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, px.x, px.y);
CHECK(e.kind == SplineEditKind::kGrab);
CHECK(e.index == 1);
}
static void testLeftClickInEmptySpaceAdds() {
const VelocityCurve c = VelocityCurve::rampDown();
// Well away from either endpoint's drawn position and from the traced line's nodes.
const SplineEdit e = resolveSplineEdit(c, kBox, SplineGesture::kLeft, 160, 60);
CHECK(e.kind == SplineEditKind::kAdd);
CHECK(e.index == -1); // no point yet — the caller creates it
}
static void testRightClickOnANodeDeletesAndElsewhereDoesNothing() {
const VelocityCurve c = threePoint();
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kRight, px.x, px.y);
CHECK(hit.kind == SplineEditKind::kDelete);
CHECK(hit.index == 1);
// Right-click on empty canvas must NOT add — delete is the only thing the gesture means.
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kRight, 160, 60);
CHECK(miss.kind == SplineEditKind::kNone);
}
static void testControlClickOnANodeTogglesHardAndElsewhereDoesNothing() {
const VelocityCurve c = threePoint();
const auto px = c.pixelFromPoint(kBox, c.points()[1]);
const SplineEdit hit = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, px.x, px.y);
CHECK(hit.kind == SplineEditKind::kToggleHard);
CHECK(hit.index == 1);
const SplineEdit miss = resolveSplineEdit(c, kBox, SplineGesture::kControlLeft, 160, 60);
CHECK(miss.kind == SplineEditKind::kNone);
}
// The popup draws its box inset inside a border; a click in that ring must be able to GRAB an
// endpoint handle (which is drawn on the box edge, within the pick radius of the ring) but must
// never ADD — an added point there clamps onto an endpoint's x and stacks an undeletable
// duplicate.
static void testOutsideTheBoxGrabsButNeverAdds() {
const VelocityCurve c = VelocityCurve::rampDown();
const auto first = c.pixelFromPoint(kBox, c.points()[0]);
const SplineEdit ring =
resolveSplineEdit(c, kBox, SplineGesture::kLeft, first.x - 3, first.y - 3);
CHECK(ring.kind == SplineEditKind::kGrab);
CHECK(ring.index == 0);
// Far outside, on no node at all.
const SplineEdit away = resolveSplineEdit(c, kBox, SplineGesture::kLeft, kBox.left - 60,
kBox.top - 40);
CHECK(away.kind == SplineEditKind::kNone);
}
static void testDegenerateBoxResolvesToNothing() {
const VelocityCurve c = threePoint();
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 0, 40}, SplineGesture::kLeft, 0, 0)
.kind == SplineEditKind::kNone);
CHECK(resolveSplineEdit(c, VelocityCurve::Box{0, 0, 40, 1}, SplineGesture::kLeft, 0, 0)
.kind == SplineEditKind::kNone);
}
// The overlay's box is the FULL area — no inset — so the contour spans the sample's whole
// drawn width 1:1 with its time axis.
static void testOverlayBoxIsTheWholeArea() {
const OverlayArea area{Rect::ltrb(12, 40, 812, 240)};
const VelocityCurve::Box box = splineOverlayBox(area);
CHECK(box.left == 12);
CHECK(box.top == 40);
CHECK(box.width == 800);
CHECK(box.height == 200);
}
// --- Smallest-target-first: resolveWaveformClaim, the shell's own comparison chain -----
//
// editor_input_waveform.cpp's mouseDownWaveform resolves a click among a contour node (a fixed
// pick box), the crossfade tab, and a marker's full-height column by calling
// resolveWaveformClaim with each candidate's own target area; the smallest hit wins. These
// tests build the real geometry over the pure primitives the shell composes, then feed it into
// resolveWaveformClaim itself, so a reverted node-first/marker-first/tab-first ordering fails
// them — pinning the mechanism, not just the input geometry it acts on. A realistic band height
// (kWaveformMinHeight, the product's own floor) is used throughout so these numbers are the
// worst case for the node, not a favourable one.
static VelocityCurve::Box boxOf(const Rect& r) { return VelocityCurve::Box{r.x, r.y, r.width, r.height}; }
constexpr std::int64_t kNodeSide = 2 * kCurveNodeGrabRadius + 1;
constexpr std::int64_t kNodeArea = kNodeSide * kNodeSide; // 169, fixed
// Case (a): a fresh Spline default (rampDown) puts its endpoint 0 at (box.left, box.top) — the
// exact pixel the start marker draws at frame 0. The node's fixed 169px pick box is far smaller
// than a kWaveformMinHeight-tall marker column, so the endpoint stays reachable.
static void testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero() {
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
const OverlayArea overlay = overlayOf(a);
const std::int64_t frames = 100000;
const VelocityCurve contour = VelocityCurve::rampDown();
const VelocityCurve::Box box = boxOf(a);
CHECK(contour.pointAtPixel(box, a.x, a.y) == 0); // endpoint 0 sits at (box.left, box.top)
const std::int64_t markers[1] = {0};
CHECK(markerAtPoint(overlay, frames, markers, 1, a.x, a.y) == 0); // the coincidence
const std::int64_t markerArea =
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
CHECK(kNodeArea < markerArea); // the node wins: the endpoint stays a genuine grab target
const WaveformClaim node{true, kNodeArea};
const WaveformClaim marker{true, markerArea};
CHECK(resolveWaveformClaim(node, WaveformClaim{}, marker, SplineGesture::kLeft) ==
WaveformClaimant::kNode);
}
// Case (b): the crossfade tab at zero crossfade sits on loopStart's own pixel column; a node
// dragged to value ~0.98 lands a couple of rows below the box top — inside the tab's own
// top-strip band, where the review found the tab fully shadowed by a node-first pass.
static void testCrossfadeTabBeatsAContourNodeNearItsTopStrip() {
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
const OverlayArea overlay = overlayOf(a);
const std::int64_t frames = 100000;
const std::int64_t loopStart = 40000, crossfade = 0; // zero crossfade -> tab sits on loopStart
const int mx = frameToX(overlay, frames, loopStart - crossfade);
const Rect tabRect = markerHandleRect(overlay, frames, loopStart - crossfade);
CHECK(!tabRect.empty());
const VelocityCurve::Box box = boxOf(a);
const int ny = a.y + 3; // ~0.98 up a kWaveformMinHeight-tall box; inside the tab's top strip
VelocityCurve c = VelocityCurve::flat();
const VelocityPoint p = c.pointFromPixel(box, mx, ny);
c.addPoint(p.velocity, p.value);
CHECK(c.pointAtPixel(box, mx, ny) >= 0);
CHECK(contains(tabRect, mx, ny)); // the coincidence: both claim the same pixel
const std::int64_t tabArea = static_cast<std::int64_t>(tabRect.width) * tabRect.height; // <= 110
CHECK(tabArea < kNodeArea); // the tab wins: it stays the only affordance at zero crossfade
const WaveformClaim node{true, kNodeArea};
const WaveformClaim tab{true, tabArea};
CHECK(resolveWaveformClaim(node, tab, WaveformClaim{}, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
// The residual the review names: the node keeps its OUTER columns, one pixel past the tab's
// clipped edge but still inside its own pick radius.
const int outerX = mx + kMarkerHandleHalfWidth + 1;
CHECK(!contains(tabRect, outerX, ny));
CHECK(c.pointAtPixel(box, outerX, ny) >= 0);
}
// Case (c): a contour node coincident with a loop marker. At kWaveformMinHeight (the product's
// own floor) the column is already an order of magnitude larger than the node's fixed pick box,
// so the node wins the shared pixel while the column stays reachable everywhere the node isn't.
static void testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere() {
const Rect a = Rect{20, 10, 1000, kWaveformMinHeight};
const OverlayArea overlay = overlayOf(a);
const std::int64_t frames = 100000;
const std::int64_t loopEnd = 70000;
const int mx = frameToX(overlay, frames, loopEnd);
const std::int64_t markers[1] = {loopEnd};
const VelocityCurve::Box box = boxOf(a);
const int ny = a.y + a.height / 2; // mid-height, well clear of any tab
VelocityCurve c = VelocityCurve::flat();
const VelocityPoint p = c.pointFromPixel(box, mx, ny);
c.addPoint(p.velocity, p.value);
CHECK(c.pointAtPixel(box, mx, ny) >= 0);
CHECK(markerAtPoint(overlay, frames, markers, 1, mx, ny) == 0); // the coincidence
const std::int64_t markerArea =
static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) * a.height; // 11 * band height
CHECK(kNodeArea < markerArea); // the node wins the shared pixel
const WaveformClaim node{true, kNodeArea};
const WaveformClaim marker{true, markerArea};
CHECK(resolveWaveformClaim(node, WaveformClaim{}, marker, SplineGesture::kLeft) ==
WaveformClaimant::kNode);
// A few rows clear of the node (outside its 13px pick box, still on the marker's column)
// the marker alone claims the click.
const int farY = ny + kCurveNodeGrabRadius + 4;
CHECK(c.pointAtPixel(box, mx, farY) < 0);
CHECK(markerAtPoint(overlay, frames, markers, 1, mx, farY) == 0);
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{}, marker, SplineGesture::kLeft) ==
WaveformClaimant::kMarker);
}
// The only live tie: the crossfade tab (<=110) can equal the node (169) only off-geometry, but
// tab-vs-marker ties at overlay height 10 (kMarkerHandleHeight), where the tab's 11x10 strip
// (110) equals a marker column's 11 * 10 (110) — the tab wins, matching check order.
static void testTabWinsAGenuineTabVersusMarkerTie() {
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{true, 110}, WaveformClaim{true, 110},
SplineGesture::kLeft) == WaveformClaimant::kTab);
}
// No claimant hit at all falls through to kNone — the caller's cue to let the drawn contour take
// empty space (addOnEmptySpace) rather than starting any drag.
static void testNoHitAnywhereFallsThroughToNone() {
CHECK(resolveWaveformClaim(WaveformClaim{}, WaveformClaim{}, WaveformClaim{}, SplineGesture::kLeft) ==
WaveformClaimant::kNone);
}
// A candidate that reports hit == false must never win merely because its (unused, default)
// area of 0 looks "smallest" — hit gates a candidate out before its area is ever compared. Real
// call sites never produce hit == false with area != 0, but the arbitration still owes one
// well-defined answer to every input, not just the ones live geometry happens to produce.
static void testAMissedCandidateNeverWinsOnADegenerateZeroArea() {
const WaveformClaim missedNode{false, 0};
const WaveformClaim tab{true, 50};
const WaveformClaim marker{true, 100};
CHECK(resolveWaveformClaim(missedNode, tab, marker, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
}
// A control-click has no tab/marker meaning (only the node's hard/smooth toggle answers it), so
// it resolves to the node whenever the node is in the running, even where a plain left-click at
// the same pixel would hand the tab or marker the win on area alone.
static void testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker() {
const WaveformClaim node{true, kNodeArea};
const WaveformClaim smallerTab{true, 50}; // would beat the node on a plain left-click
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kLeft) ==
WaveformClaimant::kTab);
CHECK(resolveWaveformClaim(node, smallerTab, WaveformClaim{}, SplineGesture::kControlLeft) ==
WaveformClaimant::kNode);
// No node in the running: control-click has nothing to fall back to, so the tab still wins.
CHECK(resolveWaveformClaim(WaveformClaim{}, smallerTab, WaveformClaim{},
SplineGesture::kControlLeft) == WaveformClaimant::kTab);
}
int main() {
testLeftClickOnANodeGrabsIt();
testLeftClickInEmptySpaceAdds();
testRightClickOnANodeDeletesAndElsewhereDoesNothing();
testControlClickOnANodeTogglesHardAndElsewhereDoesNothing();
testOutsideTheBoxGrabsButNeverAdds();
testDegenerateBoxResolvesToNothing();
testOverlayBoxIsTheWholeArea();
testFreshRampDownEndpointBeatsTheStartMarkerAtFrameZero();
testCrossfadeTabBeatsAContourNodeNearItsTopStrip();
testContourNodeBeatsALoopMarkerAtTheirSharedPixelButNotElsewhere();
testTabWinsAGenuineTabVersusMarkerTie();
testNoHitAnywhereFallsThroughToNone();
testAMissedCandidateNeverWinsOnADegenerateZeroArea();
testControlClickAlwaysTakesTheNodeOverASmallerTabOrMarker();
if (g_fail == 0) std::printf("spline_edit: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+570
View File
@@ -0,0 +1,570 @@
// Standalone tests for the SPLINE EG system — no VST3, no REAPER, no framework. One file for
// the whole feature because its seams span three modules that only mean something together: the
// shared spline (engine), the dual Staged/Spline state and its wire format (map), and the
// point-editing grammar (ui).
//
// The eleven cases below are the spec's own test list, in its order. Each is named for the rule
// it pins, so a failure names the behaviour rather than the module.
#include "../src/core/instrument/engine/voice_engine.h"
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/ui/deck_groups.h"
#include "../src/core/instrument/ui/spline_edit.h"
#include <cmath>
#include <cstdio>
#include <vector>
using namespace reasampler;
using namespace reasampler::instrument::engine;
using reasampler::instrument::map::ComponentState;
using reasampler::instrument::map::InstrumentParams;
using reasampler::instrument::map::PlaySeconds;
using reasampler::instrument::map::deserializeComponentState;
using reasampler::instrument::map::kParamsFormatMarker;
using reasampler::instrument::map::kParamsPayloadVersion;
using reasampler::instrument::map::resolvePlay;
using reasampler::instrument::map::serializeComponentState;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; }
// x runs over the curve's canonical span; a spline EG's phase maps onto it linearly.
static double xAt(double phase) { return kCurveXMin + phase * (kCurveXMax - kCurveXMin); }
// A contour that rises then falls, with a peak at x=64 the caller can make hard or smooth.
static VelocityCurve peakContour(bool hardPeak) {
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.0}, {32.0, 0.2}, {64.0, 1.0}, {96.0, 0.3}, {127.0, 0.0}},
CurveDomain::Unipolar);
if (hardPeak) c.setHard(2, true);
return c;
}
// --- 1. A hard point is a genuine slope discontinuity -------------------------
// The whole hard-point enhancement in one assertion: at a hard knot each side's one-sided slope
// is that SEGMENT'S OWN secant — no smoothing was applied on either side — so the two disagree
// and the contour has a real corner. The same knot left smooth pins to a shared tangent.
static void testHardPointGivesDifferingOneSidedSlopes() {
const VelocityCurve hard = peakContour(/*hardPeak=*/true);
const double h = 1e-4;
const double left = (hard.eval(64.0) - hard.eval(64.0 - h)) / h;
const double right = (hard.eval(64.0 + h) - hard.eval(64.0)) / h;
// The two adjacent secants: (1.0-0.2)/32 rising, (0.3-1.0)/32 falling.
const double secantIn = (1.0 - 0.2) / 32.0;
const double secantOut = (0.3 - 1.0) / 32.0;
CHECK(near(left, secantIn, 1e-4));
CHECK(near(right, secantOut, 1e-4));
CHECK(left > 0.0 && right < 0.0); // a genuine corner, not a slope that merely changes rate
// Neither adjacent segment is straightened by the hard knot — each is still a curve, which
// is what "one or more monotone splines joined at their angles" means. A straight segment
// would put its midpoint exactly on the chord.
CHECK(!near(hard.eval(48.0), (0.2 + 1.0) / 2.0, 1e-6));
CHECK(!near(hard.eval(80.0), (1.0 + 0.3) / 2.0, 1e-6));
// Left smooth, the same knot is a local extremum: Fritsch-Carlson pins the tangent to 0 on
// BOTH sides, so the slope is continuous there.
const VelocityCurve smooth = peakContour(/*hardPeak=*/false);
const double sLeft = (smooth.eval(64.0) - smooth.eval(64.0 - h)) / h;
const double sRight = (smooth.eval(64.0 + h) - smooth.eval(64.0)) / h;
CHECK(near(sLeft, 0.0, 1e-4));
CHECK(near(sRight, 0.0, 1e-4));
}
// --- 2. Per-segment monotonicity, on a contour that is not globally monotone ---
static void testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour() {
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.2}, {20.0, 0.9}, {50.0, 0.1}, {90.0, 0.85}, {110.0, 0.15}, {127.0, 0.6}},
CurveDomain::Unipolar);
c.setHard(2, true); // one hard knot, so the guarantee is asserted across a joint too
const std::vector<VelocityPoint>& pts = c.points();
for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
const double lo = (std::min)(pts[i].value, pts[i + 1].value);
const double hi = (std::max)(pts[i].value, pts[i + 1].value);
for (int s = 0; s <= 200; ++s) {
const double x = pts[i].velocity +
(pts[i + 1].velocity - pts[i].velocity) * (s / 200.0);
const double y = c.eval(x);
CHECK(y >= lo - 1e-12);
CHECK(y <= hi + 1e-12);
}
}
// ...and it genuinely rises AND falls, so the assertion above is not vacuously about a
// monotone curve.
CHECK(c.eval(20.0) > c.eval(0.0));
CHECK(c.eval(50.0) < c.eval(20.0));
}
// --- 3. The 128-point ceiling refuses without disturbing the contour ----------
static void testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical() {
VelocityCurve c = VelocityCurve::rampDown();
for (std::size_t i = 0; c.size() < kMaxCurvePoints; ++i) {
const double x = 1.0 + static_cast<double>(i);
CHECK(c.addPoint(x, 0.5) >= 0);
}
CHECK(c.size() == kMaxCurvePoints);
const std::vector<VelocityPoint> before = c.points();
CHECK(c.addPoint(63.5, 0.25) == -1);
const std::vector<VelocityPoint>& after = c.points();
CHECK(after.size() == before.size());
for (std::size_t i = 0; i < before.size(); ++i) {
// Bit-identical, not merely close: a refused add must not perturb a drawn shape at all.
CHECK(after[i].velocity == before[i].velocity);
CHECK(after[i].value == before[i].value);
CHECK(after[i].hard == before[i].hard);
}
}
// --- 4. The two full-length endpoints always survive --------------------------
static void testEndpointDeletionIsRefused() {
VelocityCurve c = peakContour(false);
const std::size_t n = c.size();
CHECK(!c.deletePoint(0));
CHECK(!c.deletePoint(n - 1));
CHECK(c.size() == n);
CHECK(c.points().front().velocity == kCurveXMin);
CHECK(c.points().back().velocity == kCurveXMax);
// An interior point still deletes, so the refusal is about the endpoints and not about
// deletion being broken.
CHECK(c.deletePoint(2));
CHECK(c.size() == n - 1);
}
// --- 5. The hard/smooth toggle round-trips ------------------------------------
static void testTogglingHardThenSmoothRestoresTheEvaluatedContour() {
VelocityCurve c = peakContour(false);
std::vector<double> baseline;
for (int i = 0; i <= 127; ++i) baseline.push_back(c.eval(i));
CHECK(c.toggleHard(2));
bool changedSomewhere = false;
for (int i = 0; i <= 127; ++i) {
if (!near(c.eval(i), baseline[static_cast<std::size_t>(i)], 1e-12)) changedSomewhere = true;
}
CHECK(changedSomewhere); // the toggle must actually do something, or the round trip is empty
CHECK(c.points()[2].hard);
CHECK(c.toggleHard(2));
CHECK(!c.points()[2].hard);
for (int i = 0; i <= 127; ++i) {
CHECK(c.eval(i) == baseline[static_cast<std::size_t>(i)]); // exact, not approximate
}
}
// --- 6. Both states survive a mode flip, in memory and across save/reload -----
// Distinctive staged values on all three envelopes, so "exactly as left" is checkable rather
// than accidentally equal to a default.
static InstrumentParams paramsWithBothStates() {
InstrumentParams p;
p.play.playMode = PlayMode::Gate;
p.play.adsr.attackSeconds = 0.37;
p.play.adsr.decaySeconds = 0.21;
p.play.adsr.sustainLevel = 0.42;
p.play.adsr.releaseSeconds = 0.66;
p.play.pitchEnv.enabled = true;
p.play.pitchEnv.peakSemitones = -7.5;
p.play.pitchEnv.shape.attackSeconds = 0.11;
p.play.filter.enabled = true;
p.play.filter.env.decaySeconds = 0.29;
p.play.filter.env.sustainLevel = 0.33;
VelocityCurve drawn = peakContour(/*hardPeak=*/true);
p.play.ampSpline.mode = EnvMode::Spline;
p.play.ampSpline.contour = drawn;
p.play.pitchSpline.contour = drawn; // stored, but left Staged: the inactive half
p.play.filterSpline.contour = drawn;
return p;
}
static void testStagedAndSplineStatesBothSurviveAFlipAndASaveReload() {
InstrumentParams p = paramsWithBothStates();
const VelocityCurve drawn = p.play.ampSpline.contour;
// In memory: flipping the amp back to Staged keeps the contour, and forward again keeps the
// staged values. Neither converts into the other.
p.play.ampSpline.mode = EnvMode::Staged;
CHECK(p.play.ampSpline.contour.equals(drawn));
CHECK(p.play.adsr.attackSeconds == 0.37);
p.play.ampSpline.mode = EnvMode::Spline;
CHECK(p.play.adsr.sustainLevel == 0.42);
ComponentState st;
st.selectionId = "cap-1";
st.params = p;
const ComponentState back = deserializeComponentState(serializeComponentState(st), 48000.0);
const PlaySeconds& r = back.params.play;
CHECK(r.adsr.attackSeconds == 0.37);
CHECK(r.adsr.decaySeconds == 0.21);
CHECK(r.adsr.sustainLevel == 0.42);
CHECK(r.adsr.releaseSeconds == 0.66);
CHECK(r.pitchEnv.peakSemitones == -7.5);
CHECK(r.pitchEnv.shape.attackSeconds == 0.11);
CHECK(r.filter.env.decaySeconds == 0.29);
CHECK(r.filter.env.sustainLevel == 0.33);
CHECK(r.ampSpline.mode == EnvMode::Spline);
CHECK(r.pitchSpline.mode == EnvMode::Staged);
CHECK(r.filterSpline.mode == EnvMode::Staged);
// The contour itself, hard flags included, on all three — the inactive ones too.
CHECK(r.ampSpline.contour.equals(drawn));
CHECK(r.pitchSpline.contour.equals(drawn));
CHECK(r.filterSpline.contour.equals(drawn));
CHECK(r.ampSpline.contour.points()[2].hard);
}
// --- 7. A v12 payload still loads ---------------------------------------------
// Rewrites the params-payload version field to 12, leaving the v12 prefix byte-identical (v13
// is a strict suffix, so the prefix IS what a v12 writer emitted). The reader is positional and
// bounded, so it stops before the appended tail and never sees it.
static std::vector<std::uint8_t> asV12Payload(std::vector<std::uint8_t> bytes) {
int patched = 0;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t marker = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
const std::uint32_t ver = static_cast<std::uint32_t>(bytes[i + 4]) |
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
if (marker != kParamsFormatMarker || ver != kParamsPayloadVersion) continue;
bytes[i + 4] = 12;
++patched;
}
CHECK(patched == 1); // exactly one payload header, or the rewrite is meaningless
return bytes;
}
static void testAV12PayloadLoadsWithoutLoss() {
InstrumentParams p = paramsWithBothStates();
p.play.playMode = PlayMode::Trigger; // a v12 project can hold any mode
p.velocityCurve.addPoint(70.0, 0.4);
p.velocityCurve.setHard(1, true);
ComponentState st;
st.selectionId = "cap-legacy";
st.params = p;
const ComponentState back =
deserializeComponentState(asV12Payload(serializeComponentState(st)), 48000.0);
const PlaySeconds& r = back.params.play;
// Everything v12 carried comes through untouched.
CHECK(back.selectionId == "cap-legacy");
CHECK(r.playMode == PlayMode::Trigger);
CHECK(r.adsr.attackSeconds == 0.37);
CHECK(r.adsr.sustainLevel == 0.42);
CHECK(r.pitchEnv.peakSemitones == -7.5);
CHECK(r.filter.env.decaySeconds == 0.29);
CHECK(back.params.velocityCurve.size() == 3);
CHECK(near(back.params.velocityCurve.eval(70.0), 0.4));
// Everything v13 added lifts to its default: Staged on all three, the y = 1 - x contour,
// and no hard flag anywhere (v12 had nowhere to store one).
CHECK(r.ampSpline.mode == EnvMode::Staged);
CHECK(r.pitchSpline.mode == EnvMode::Staged);
CHECK(r.filterSpline.mode == EnvMode::Staged);
CHECK(r.ampSpline.contour.equals(VelocityCurve::rampDown()));
CHECK(!back.params.velocityCurve.points()[1].hard);
}
// --- 8. A stored contour rescales to a different-length sample ---------------
static SampleData splineAmpSample(std::size_t frames, const VelocityCurve& contour) {
SampleData s;
s.frames.assign(frames, 1.0f); // DC: the rendered value IS the envelope
s.rootNote = 60;
s.sampleRate = 48000;
s.play.playMode = PlayMode::Trigger;
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = contour;
return s;
}
static std::vector<double> renderVoice(const SampleData& s, std::size_t frames) {
Voice v;
v.start(60, 100, s);
std::vector<double> out;
out.reserve(frames);
for (std::size_t i = 0; i < frames; ++i) out.push_back(v.renderFrame());
return out;
}
static void testAContourReplaysProportionallyOnADifferentLengthSample() {
const VelocityCurve contour = peakContour(/*hardPeak=*/true);
const std::size_t shortLen = 1000;
const std::size_t longLen = 3000;
const SampleData s1 = splineAmpSample(shortLen, contour);
const SampleData s2 = splineAmpSample(longLen, contour);
const std::vector<double> a = renderVoice(s1, shortLen);
const std::vector<double> b = renderVoice(s2, longLen);
// The rendered value at frame i of the short sample is the contour at phase i/shortLen; the
// long sample reaches the SAME phase at frame 3i. Shape preserved, proportionally.
for (std::size_t i = 1; i + 1 < shortLen; ++i) {
const double phase = static_cast<double>(i) / static_cast<double>(shortLen);
CHECK(near(a[i], contour.eval(xAt(phase)), 1e-6));
CHECK(near(b[i * 3], a[i], 1e-6));
}
// Not a flat contour, so the agreement above is a real shape match.
CHECK(a[shortLen / 2] > a[10] + 0.2);
}
// --- Regression: a 2-point contour starting at 0 is not a terminus at frame 0 -----------
//
// onFinalSegment() (seg_+2==n_) is trivially true for a 2-point contour. Gating the amp
// spline's early-free on that alone reads a contour's OWN opening value as the note's end,
// so the simplest fade-in (left knot dragged to the box floor) went silent at frame 0. The
// fix requires the TERMINAL value (the segment's right endpoint) to be 0, not just the
// segment index.
static void testTwoPointContourRisingFromZeroSoundsForItsFullSpan() {
const VelocityCurve contour =
VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 1.0}}, CurveDomain::Unipolar);
const std::size_t frames = 1000;
const SampleData s = splineAmpSample(frames, contour);
Voice v;
v.start(60, 100, s);
CHECK(v.soundingNote()); // fresh note: sounding before anything is rendered
const double y0 = v.renderFrame();
CHECK(near(y0, 0.0, 1e-9)); // the contour's own value at phase 0 IS 0 ...
CHECK(v.soundingNote()); // ...but the note itself must not be over yet
for (std::size_t i = 1; i < frames / 2; ++i) v.renderFrame();
CHECK(v.soundingNote()); // still sounding at the midpoint, rising toward 1
}
// The positive direction of the fix above: a contour whose final segment is flat at 0 (here,
// the whole two-point span) DOES free the voice early, on its very first tick. Nothing exercises
// this without it — a future tightening of the gate (e.g. requiring more than onFinalSegment() +
// segmentEndValue()) could silently turn the early-free off, which is a performance regression
// (a ringing but silent voice) rather than an audible one, so nothing else would catch it.
static void testFlatZeroFinalSegmentStillFreesTheVoiceEarly() {
const VelocityCurve contour =
VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 0.0}}, CurveDomain::Unipolar);
const std::size_t frames = 1000;
const SampleData s = splineAmpSample(frames, contour);
Voice v;
v.start(60, 100, s);
CHECK(v.soundingNote()); // fresh note: sounding before anything is rendered
const double y0 = v.renderFrame();
CHECK(near(y0, 0.0, 1e-9));
CHECK(!v.soundingNote()); // a genuine permanent terminus, not a mid-contour dip
}
// The timing, not just the fact: the case above is trivially "early" (a wholly-flat contour
// frees on frame 0), which can't distinguish "frees early" from "frees at the right frame." This
// fixture's final segment starts MID-sample, so the free must land there, not at frame 0 and not
// at the sample's natural end. The breakpoint (phase 0.5495) is deliberately off every sampled
// frame's exact phase (k/1000), so no sampled frame lands on the segment boundary itself and
// which segment "owns" that frame is never ambiguous.
static void testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero() {
const double breakpointPhase = 0.5495;
const VelocityCurve contour = VelocityCurve::fromPoints(
{{xAt(0.0), 1.0}, {xAt(breakpointPhase), 0.0}, {xAt(1.0), 0.0}}, CurveDomain::Unipolar);
const std::size_t frames = 1000;
const SampleData s = splineAmpSample(frames, contour);
Voice v;
v.start(60, 100, s);
// Frames 0..549 (phase < breakpoint) sit on the declining first segment: still sounding.
for (std::size_t i = 0; i < 550; ++i) {
v.renderFrame();
CHECK(v.soundingNote());
}
// Frame 550 (phase 0.55) is the first sampled frame past the breakpoint, on the flat-zero
// final segment — this is where the early-free fires.
const double y550 = v.renderFrame();
CHECK(near(y550, 0.0, 1e-9));
CHECK(!v.soundingNote());
}
// --- 9. A fresh spline EG opens on the smooth y = 1 - x ----------------------
static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() {
const SplineEnv fresh;
CHECK(fresh.mode == EnvMode::Staged); // drawn is opt-in; the CONTOUR is what defaults here
const VelocityCurve& c = fresh.contour;
CHECK(c.size() == 2);
CHECK(!c.points()[0].hard);
CHECK(!c.points()[1].hard);
// Two collinear knots reduce the Hermite tangents to the shared secant, so it is an exact
// straight line — and a straight line is smooth.
for (int i = 0; i <= 127; ++i) {
CHECK(near(c.eval(i), 1.0 - static_cast<double>(i) / 127.0, 1e-12));
}
}
// --- 10. Gate is unavailable while a spline EG is active ---------------------
// The rule has one home (splineActive) and one enforcement point on the way to the engine
// (resolvePlay). The editor's Gate segment refuses and paints Disabled off the same predicate.
//
// Pitch and filter additionally gate on their own `enabled` flag, matching Voice::start's
// binder (voice.cpp only binds pitchSplineCur_/filterSplineCur_ under that same condition): a
// Spline mode flip on a still-disabled envelope produces no modulation, so it must not cost
// Gate either — the predicate and the binder must agree on one enable rule. Amp has no such
// flag and counts on its mode alone.
static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() {
PlaySeconds stored;
stored.playMode = PlayMode::Gate;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.ampSpline.mode = EnvMode::Spline;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.ampSpline.mode = EnvMode::Staged;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.pitchSpline.mode = EnvMode::Spline;
CHECK(!splineActive(stored)); // pitchEnv.enabled is still false: no modulation, no cost
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.pitchEnv.enabled = true;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.pitchSpline.mode = EnvMode::Staged;
stored.pitchEnv.enabled = false;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.filterSpline.mode = EnvMode::Spline;
CHECK(!splineActive(stored)); // filter.enabled is still false: the filter is fully off
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
stored.filter.enabled = true;
CHECK(splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
stored.filterSpline.mode = EnvMode::Staged;
stored.filter.enabled = false;
CHECK(!splineActive(stored));
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
// And the staged knobs of a drawn envelope go inert — drawn-but-dead, not removed — while
// its depth knob, which scales either shape, stays live.
using namespace reasampler::instrument::ui;
DeckEnableState gates;
gates.pitchEnvEnabled = true;
gates.filterEnabled = true;
CHECK(!deckKnobInert(DeckParam::kAttack, gates));
gates.ampSpline = true;
CHECK(deckKnobInert(DeckParam::kAttack, gates));
CHECK(deckKnobInert(DeckParam::kSustain, gates));
CHECK(deckKnobInert(DeckParam::kTrigDecay, gates));
gates.pitchSpline = true;
CHECK(deckKnobInert(DeckParam::kPitchEnvAttack, gates));
CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates));
gates.filterSpline = true;
CHECK(deckKnobInert(DeckParam::kFilterEnvRelease, gates));
CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates));
}
// enforceGateUnavailableWhileDrawn (play_params.h) is the ONE enforcement resolvePlay and the
// editor's applyControl both call — resolvePlay's own coverage above only exercises it through
// the frames mirror; pin it directly over BOTH representations it is shared between, closing the
// coverage gap the extraction was for (applyControl has no shell test target of its own).
static void testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations() {
PlaySeconds seconds;
seconds.playMode = PlayMode::Gate;
enforceGateUnavailableWhileDrawn(seconds);
CHECK(seconds.playMode == PlayMode::Gate); // not splineActive -> untouched
seconds.ampSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(seconds);
CHECK(seconds.playMode == PlayMode::Trigger);
PlayParams frames;
frames.playMode = PlayMode::Gate;
frames.filter.enabled = true;
frames.filterSpline.mode = EnvMode::Spline;
enforceGateUnavailableWhileDrawn(frames);
CHECK(frames.playMode == PlayMode::Trigger);
}
// --- 11. The velocity->amp curve is the same grammar -------------------------
static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() {
using namespace reasampler::instrument::ui;
const VelocityCurve::Box box{100, 50, 127, 101};
VelocityCurve amp = VelocityCurve::flat();
CHECK(amp.addPoint(64.0, 0.25) == 1);
const auto px = amp.pixelFromPoint(box, amp.points()[1]);
// Control-click resolves to the toggle — the identical resolution the spline EG overlay
// gets, because it is the identical function.
const SplineEdit toggle =
resolveSplineEdit(amp, box, SplineGesture::kControlLeft, px.x, px.y);
CHECK(toggle.kind == SplineEditKind::kToggleHard);
CHECK(toggle.index == 1);
const double smoothMid = amp.eval(48.0);
CHECK(amp.toggleHard(1));
CHECK(amp.points()[1].hard);
CHECK(!near(amp.eval(48.0), smoothMid, 1e-9)); // the hard flag reaches the amp response
// Right-click delete is unchanged: it resolves on an interior node and the endpoint guard
// still refuses the two ends.
const SplineEdit del = resolveSplineEdit(amp, box, SplineGesture::kRight, px.x, px.y);
CHECK(del.kind == SplineEditKind::kDelete);
CHECK(del.index == 1);
CHECK(amp.deletePoint(1));
CHECK(amp.size() == 2);
CHECK(!amp.deletePoint(0));
CHECK(!amp.deletePoint(1));
}
// --- 12. SplineCursor's binary-search branch agrees with the cold reader ------
// Test 8 only walks a monotone forward read, which never leaves SplineCursor::locate's
// select(seg_+1) fast path. A backwards/jumping read forces the actual binary search — and at
// a duplicate-X knot (a drawn step) the RT cursor must resolve to the SAME point the cold
// VelocityCurve::eval() would, or a backwards read audibly steps to the wrong side of the step.
static void testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead() {
// A step at x=64: two knots sharing an X but different Y.
VelocityCurve c = VelocityCurve::fromPoints(
{{0.0, 0.1}, {32.0, 0.3}, {64.0, 0.9}, {64.0, 0.2}, {96.0, 0.6}, {127.0, 0.4}},
CurveDomain::Unipolar);
SplineCursor cur;
cur.bind(c);
// Deliberately out of order, so every eval but the first forces locate()'s binary search
// rather than the forward-walk fast path.
const double xs[] = {100.0, 10.0, 64.0, 40.0, 64.0, 5.0, 127.0, 20.0, 0.0, 90.0};
for (double x : xs) {
const double phase = x / kCurveXMax;
CHECK(near(cur.eval(phase), c.eval(x), 1e-6));
}
// The duplicate knot itself: both readers resolve to the SAME one (the first, per
// VelocityCurve::eval's "first containing segment" rule).
CHECK(near(cur.eval(64.0 / kCurveXMax), 0.9, 1e-6));
}
int main() {
testHardPointGivesDifferingOneSidedSlopes();
testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour();
testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical();
testEndpointDeletionIsRefused();
testTogglingHardThenSmoothRestoresTheEvaluatedContour();
testStagedAndSplineStatesBothSurviveAFlipAndASaveReload();
testAV12PayloadLoadsWithoutLoss();
testAContourReplaysProportionallyOnADifferentLengthSample();
testTwoPointContourRisingFromZeroSoundsForItsFullSpan();
testFlatZeroFinalSegmentStillFreesTheVoiceEarly();
testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero();
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
+2 -1
View File
@@ -379,7 +379,8 @@ static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
// column to the start marker (index 0) at this x/y... // column to the start marker (index 0) at this x/y...
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0); CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0);
// ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell // ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell
// resolves by asking the handle first, same as it does for the zero-fade/loop-start case. // resolves by smallest-target-first (the handle's clipped tab is always the narrower
// target), same as it does for the zero-fade/loop-start case.
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY)); CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
} }