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:
@@ -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 (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
- `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 Fritsch–Carlson monotone cubic Hermite spline (no overshoot). `eval(velocity)` called once per note-on. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation 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 Fritsch–Carlson 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
? filterEnv_.tick()
|
? filterSplineCur_.eval(splinePhase())
|
||||||
: filterAhd_.amplitudeAt(sourceOffset());
|
: ((playMode_ == PlayMode::Gate)
|
||||||
|
? filterEnv_.tick()
|
||||||
|
: 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
||||||
const int segW = g.captionToggle.segWidth;
|
const auto placeToggle = [&](const DeckToggleDesc& d, DeckToggleLayout& into) {
|
||||||
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
|
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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
editedCurve().deletePoint(static_cast<std::size_t>(curveIdx));
|
if (kind == DragKind::kCurveNode) {
|
||||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
|
editedCurve().deletePoint(static_cast<std::size_t>(curveIdx));
|
||||||
|
} else {
|
||||||
|
splineFor(overlayEnv_).deletePoint(static_cast<std::size_t>(curveIdx));
|
||||||
|
}
|
||||||
|
hover_ = HoverTarget{}; // stale node index would light a shifted node
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commitAndReload();
|
commitAndReload();
|
||||||
|
|||||||
@@ -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,37 +45,28 @@ 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
|
const VelocityPoint p = editedCurve().pointFromPixel(box, x, y);
|
||||||
// endpoint. A ring click can still grab an existing node (handled above).
|
idx = editedCurve().addPoint(p.velocity, p.value);
|
||||||
if (idx < 0) {
|
if (idx < 0) return; // at the point ceiling: refused, curve untouched
|
||||||
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);
|
|
||||||
idx = static_cast<int>(editedCurve().addPoint(p.velocity, p.value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
|
||||||
|
|
||||||
drag_ = DragKind::kCurveNode;
|
drag_ = DragKind::kCurveNode;
|
||||||
curvePointIndex_ = idx;
|
curvePointIndex_ = idx;
|
||||||
dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base
|
dragStartCurve_ = editedCurve(); // AFTER the add — resolvePointDrag's delta base
|
||||||
@@ -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);
|
||||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
if (curvePopup_ != CurveTarget::kNone) {
|
||||||
if (!contains(pl.curveBox, x, y)) return;
|
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
if (!contains(pl.curveBox, x, y)) return;
|
||||||
const int idx = editedCurve().pointAtPixel(box, x, y);
|
const SplineEdit edit = resolveSplineEdit(editedCurve(), curveBoxFromRect(pl.curveBox),
|
||||||
if (idx < 0) return;
|
SplineGesture::kRight, x, y);
|
||||||
if (editedCurve().deletePoint(static_cast<std::size_t>(idx))) {
|
if (edit.kind != SplineEditKind::kDelete) return;
|
||||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
if (editedCurve().deletePoint(static_cast<std::size_t>(edit.index))) {
|
||||||
commitAndReload();
|
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,11 +63,19 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#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,50 +33,152 @@ 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) {
|
}
|
||||||
drag_ = DragKind::kEnvNode;
|
|
||||||
envNode_ = nh.node;
|
const SetupMarkers m = pickedMarkers(frames);
|
||||||
dragStartX_ = x;
|
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||||
dragStartY_ = y;
|
|
||||||
dragStartEnv_ = env;
|
// Three affordances can claim the same pixel: a node (the staged envelope's or the drawn
|
||||||
dragSampleFrames_ = frames;
|
// contour's — a small fixed pick box either way), the crossfade tab (a small clipped
|
||||||
dragStartParams_ = params_;
|
// top-strip tab), and a marker's full-height grab column (waveform_view.h's tab-vs-column
|
||||||
return true; // node moves once the cursor drags
|
// 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 SetupMarkers m = pickedMarkers(frames);
|
|
||||||
// The crossfade handle first, and only when there IS a loop to fade: at a zero fade it
|
const Rect tabRect =
|
||||||
// sits exactly on the loop start, so it can only stay reachable by owning the top strip
|
m.hasLoop ? markerHandleRect(overlay, frames, m.loopStart - m.crossfade) : Rect{};
|
||||||
// (waveform_view.h's handle-vs-column split) and being asked first. The same ambiguity
|
const WaveformClaim tab = (m.hasLoop && contains(tabRect, x, y))
|
||||||
// recurs whenever ANY marker's frame lands on loopStart - crossfade (most plausibly the
|
? WaveformClaim{true, static_cast<std::int64_t>(tabRect.width) *
|
||||||
// start marker dragged up against the fade edge), so this check has to run before the
|
tabRect.height}
|
||||||
// marker array below regardless of which marker the collision is with.
|
: WaveformClaim{};
|
||||||
if (m.hasLoop &&
|
|
||||||
contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) {
|
// Nominal, not actual: markerAtPoint clips the column at the overlay edges (a marker at
|
||||||
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
|
// frame 0 has 6 usable columns, not 11) and the node's fixed side clips too at a pick-box
|
||||||
return true;
|
// 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 std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
const int markerHit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
|
||||||
const int hit = markerAtPoint(overlay, frames, markerFrames, 3, x, y);
|
const WaveformClaim marker =
|
||||||
if (hit >= 0) {
|
(markerHit >= 0)
|
||||||
beginMarkerDrag(static_cast<WaveMarker>(hit), m, frames, x);
|
? WaveformClaim{true, static_cast<std::int64_t>(2 * kMarkerGrabWidth + 1) *
|
||||||
return true;
|
overlay.rect.height}
|
||||||
|
: WaveformClaim{};
|
||||||
|
|
||||||
|
switch (resolveWaveformClaim(node, tab, marker, gesture)) {
|
||||||
|
case WaveformClaimant::kNode:
|
||||||
|
if (envNodeHit.hit) {
|
||||||
|
drag_ = DragKind::kEnvNode;
|
||||||
|
envNode_ = envNodeHit.node;
|
||||||
|
dragStartX_ = x;
|
||||||
|
dragStartY_ = y;
|
||||||
|
dragStartEnv_ = env;
|
||||||
|
dragSampleFrames_ = frames;
|
||||||
|
dragStartParams_ = params_;
|
||||||
|
return true; // node moves once the cursor drags
|
||||||
|
}
|
||||||
|
return splineOverlayClick(overlay, x, y, gesture, /*addOnEmptySpace=*/false);
|
||||||
|
case WaveformClaimant::kTab:
|
||||||
|
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
|
||||||
|
return true;
|
||||||
|
case WaveformClaimant::kMarker:
|
||||||
|
beginMarkerDrag(static_cast<WaveMarker>(markerHit), m, frames, x);
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,13 +30,16 @@ 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 =
|
||||||
disabled ? InteractionState::Disabled
|
disabled ? InteractionState::Disabled
|
||||||
: (seg1Active ? InteractionState::Active
|
: (seg1Active ? InteractionState::Active
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user