instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13
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 —
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
- `engine/loop/` — the sustain loop's ONE validity/clamp fold (`resolveLoop`) plus its pre-seam crossfade geometry and the editor's default handle span; see `engine/loop/CLAUDE.md`. The voice folds it once at note-on; the crossfade weight is header-inline because it rides the per-sample read.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — 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.
|
||||
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…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…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, 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.
|
||||
- `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.
|
||||
@@ -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.
|
||||
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
|
||||
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
|
||||
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
|
||||
- `deck_groups` — also home to `isLiveDeckParam` and `liveCommitFor`, the editor's whole commit-tier routing decision (see "Live parameter delivery" above), and to `OverlayEnv` + `nextOverlaySelection`/`overlayEnvInert`, the whole overlay-selection state machine (exclusivity, the none resting state, and which selections a disabled group makes inert); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then 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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user