instrument: one staged-envelope system — per-segment curves, the sustain-less AHD, and a shared overlay for all three envelopes
Trigger's fade pair folds into the AHD (and goes live); the release anchors right; Preserve rings its synthetic tail out instead of cutting it. Payload v10.
This commit is contained in:
@@ -9,8 +9,8 @@ subdirectories:
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, the Trigger
|
||||
play-span formula).
|
||||
- **`note/`** — the programmed capture-signal model: musical-division note length, tempo
|
||||
resolution, and anchored start/end offsets — the one record and resolver a
|
||||
capture-signal popup and the offline bake read from, so they cannot diverge.
|
||||
@@ -120,13 +120,17 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
|
||||
back-compat degenerate.
|
||||
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
|
||||
length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
|
||||
no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`; amplitude ramps `0→1` over
|
||||
`fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
|
||||
clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
|
||||
(constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
|
||||
held/out of scope (fork S15-F1).
|
||||
length; note-off is ignored (the voice plays through, no sustain loop). Frame span
|
||||
`[startFrame, playEnd)` where `playEnd = startFrame +
|
||||
round(lengthFraction·(frames − startFrame))`. The amplitude over that span is the staged
|
||||
**AHD** (below), not a fade pair. **Note-off in Trigger is a no-op** — choke-on-note-off
|
||||
is held/out of scope (fork S15-F1).
|
||||
|
||||
> **Superseded, do not reintroduce:** Trigger's amplitude was once a fade-in/unity/
|
||||
> fade-out shape with its own equal-power curve and its own `fadeInFrames`/`fadeOutFrames`
|
||||
> pair, clamped so the two fades fit the span. That is retired — one staged-envelope
|
||||
> design now covers what were two mechanisms. A saved instance's fades lift onto the AHD
|
||||
> at the codec boundary (attack ← fade-in, decay ← fade-out, hold ← the remainder).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
@@ -138,12 +142,12 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
|
||||
(loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
|
||||
is a resampler that couples duration) — never wire it as the duration-preserving path.
|
||||
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
|
||||
(`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
|
||||
whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
|
||||
default** — a regression that applies pitch modulation when the envelope is disabled is
|
||||
a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
|
||||
is added to the shifter's shift amount.
|
||||
- **Pitch envelope — AHD, off by default.** A pitch-offset curve rising to `peakSemitones`
|
||||
over attack, holding, then decaying to 0, riding on top of whichever pitch engine; a zero
|
||||
attack gives a pure percussive pitch drop. **Off by default** — a regression that applies
|
||||
pitch modulation when the envelope is disabled is a bug. Its hold fraction defaults to 0,
|
||||
which is exactly the attack-decay shape it grew out of. Under Varispeed the offset is a
|
||||
per-frame multiply of `ratio_`; under Preserve it is added to the shifter's shift amount.
|
||||
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
|
||||
`process()` in steady state. **Note (supersedes an earlier framing):** the
|
||||
shifter's onset latency (~25 ms, half-window) was once described as "an
|
||||
@@ -201,19 +205,33 @@ automatable parameters."* It rejects the precedent, not one instance of it.
|
||||
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
|
||||
optional-forever; don't let their feature lists drive Tier 0–1's build shape.
|
||||
|
||||
### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
|
||||
### The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)
|
||||
|
||||
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
The overlay draws ONE envelope over the Sample view's hero waveform, and WHICH one is a
|
||||
transient editor choice: each envelope deck (amp, pitch, filter) carries a corner radio, at
|
||||
most one is overlay-active, and **none is a valid resting state — the editor opens there.**
|
||||
Never persisted; it selects what is drawn, not what is played.
|
||||
|
||||
**The overlay is directly editable — draggable nodes (SETTLED, S-VIEW-F2), plus a round
|
||||
mid-segment knot per sloped stage that sets that stage's curve exponent.** A node drag, a
|
||||
knot drag and the deck knobs are surfaces onto ONE model: all three read/write the same
|
||||
fields of the one parameter set, so an edit on any of them re-lays the others — one source
|
||||
of truth, structural (re-read-every-paint), never a listener chain. Every drag is
|
||||
range-clamped to the same per-param min/max the knobs enforce, so no drag can produce a
|
||||
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:**
|
||||
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
|
||||
fades, and one shared set could not preserve both modes' prior sound).
|
||||
|
||||
**And which LAYOUT an envelope takes follows from whether it has a sustain stage** — the
|
||||
same rule, applied once: an AHDSR right-anchors its release (the end point is fixed at the
|
||||
canvas edge and release is dragged from its top node), a sustain-less AHD maps 1:1 onto the
|
||||
waveform's time axis. The two policies coexist rather than merge; the 1:1 mapping only means
|
||||
anything for a trigger shape.
|
||||
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
@@ -235,7 +253,7 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class. `AdsrEnvelope`/`PitchEnvelope` also own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the two level steps φ cannot cover.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `AhdEnvelope` the sustain-less Attack/Hold/Decay, `PitchEnvelope` the AHD pitch offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. Also home to `fitAhd`/`ahdLevelAt`, THE span split and shape every sustain-less envelope shares. A voice carries two of each shape — the amp's and the filter's — and its play mode picks which pair it reads. `AdsrEnvelope`/`PitchEnvelope` own `applyLive` (the φ-holding mid-stage rule), its fresh-note peer `snapLive`, and `StepSmoother`, the bounded offset that absorbs the level steps φ cannot cover; `AhdEnvelope` is POSITIONAL (evaluated at a source offset, not ticked), so it has no phase to hold and smooths a live reshape instead.
|
||||
- `live_params.h` / `live_params.cpp` — the live-parameter block: `LiveValues` (the plain, trivially-copyable bundle the audio thread observes), the single-writer `LiveParams` seqlock that publishes it without a lock or a torn read, `foldLive` (the ONE derivation from `PlayParams` — every publisher goes through it so the two representations cannot drift), and `ValueRamp`, the per-frame glide whose EXACT termination is what lets the filter's equality-compare cutoff skip re-engage. Links no engine: the block is a value the voice observes, not a thing the engine owns.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||
- `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.
|
||||
@@ -247,9 +265,10 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
|
||||
- `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…v9), 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.
|
||||
- `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.
|
||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||
- `trigger_seam` — the shared Trigger play-SPAN formula: how the stored %-length becomes the source-frame span the voice plays and the overlay draws over, threading `startFrame` correctly. (Its fade frames↔fraction converters retired with the fade pair itself.)
|
||||
|
||||
### `ui/`
|
||||
|
||||
@@ -266,13 +285,13 @@ slider couldn't. Two pure modules split the forward (draw) and inverse (edit) ma
|
||||
- `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); WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||
- `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 amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`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 (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
|
||||
- `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.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **An AHDSR's overlay x-axis is schematic, not PCM-aligned** — it does NOT line up with the waveform under it; only a sustain-less AHD's x-axis is wall-clock/PCM-aligned. Don't assume a gated envelope's curve is time-accurate against the sample.
|
||||
- **An AHD's Hold is a FRACTION of what attack and decay left, never a time.** That is the whole reason A+H+D ≤ span holds by construction; adding a clamp on the sum, or re-expressing Hold as a duration, reintroduces the overflow the fraction exists to prevent.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
|
||||
Reference in New Issue
Block a user