# src/core/instrument — pure VST3-instrument core (engine / map / ui) ## Scope The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three subdirectories: - **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting, 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). - **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band interiors, waveform, keyboard strip, capture browser, param controls, envelope overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`. ## Invariants ### The three locked decisions this spec assumes (settled 2026-07-26) - **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and access to the REAPER VST-host bridge. - **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing matrix, no multi-format wrapper, no standalone-in-other-hosts concern. - **D6 — two products, tightly integrated.** A separate artifact, but not a divorced file-only companion: via the VST-host bridge it reads the live `"reasampler"` project ext-state and is project-aware. (The bridge mechanism itself is documented in `src/core/wire/CLAUDE.md`.) ### The two seams (audio via files, mapping via live state) - **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those paths the same way `persist` does (a shared convention, not a re-implementation). There is no live PCM stream across the bridge, by design. - **Live-state seam (the mapping, via the bridge).** For everything that is not raw audio — the bank index, the mapping, which project is active — the instrument reads the live `"reasampler"` ext-state via the bridge. ### One capture = one parameter set The instrument holds ONE loaded capture and ONE set of playback parameters governing it across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of captures: every playback parameter edits in exactly one place, and no gesture can express per-zone divergence. The root note survives as a first-class parameter of that one set. - **No key-range concept.** The loaded capture answers every note 0..127, repitched from its root, with key-tracking applied. A user-settable low/high playable range is re-addable later as two ordinary parameters if it is ever missed. - **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone one's capture and zone one's parameters; the rest drop, touching no file and no bank entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately relaxed for a genuinely multi-zone instance. ### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26) The split model is the settled answer, mirroring the capture/placement separation: - **Bank intrinsics (facts about the captured file) live on `Sample`.** Root note (the MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held notes) are facts about the file, added as an additive field extension (same shape as `provenance`). - **Performance choices live in the instrument.** Amplitude envelopes and per-sample tuning/gain trim are a performance choice, not a fact about a file — they belong to the instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are per-instance `ComponentState`, never written to `Sample` or the bank. ### The pure core (D3 — the load-bearing split) The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are a pure, REAPER-free, DAW-free, unit-tested module — the mirror of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the `SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE editor, and the bridge calls) is the thin shell — the only part that touches VST3 or REAPER at all. Any VST3 or REAPER type leaking into this core is a bug. - **The bank is one source; the instrument is another view of it (never a fork).** The instrument is a pure consumer of the bank — it does not copy samples, does not own a private sample store, and does not mutate the bank. - **`Sample` field additions are additive and lossless.** No existing `Sample` field changes; no `BankIndex` behavior changes. - **Relative-paths-only survives.** The instrument resolves audio via the project-relative machinery; it introduces no absolute paths. ### Channel mode — current reality **Current reality (root `CLAUDE.md`, GA post-launch pass): the output bus is permanently stereo.** `ChannelMode` is decode-only; the dynamic mono↔stereo bus renegotiation (`setBusArrangements` per-instance toggle) has been deleted. Channel mode auto-defaults from the loaded capture's channel count via a pure `channelModeFor` helper, gated by a persisted `channelModeExplicit` flag (`ComponentState` v9). Mono source + stereo mode → dual-mono (same signal both channels, centered); stereo source + mono mode → downmix (existing decode-side policy). > **Superseded design, do not reintroduce:** an earlier "Channel mode — mono | > stereo (D-E)" design specified a per-instance toggle that **dynamically > renegotiates the REAPER audio bus** via `setBusArrangements`/`getBusArrangement` > (the instrument reporting mono or stereo per instance and REAPER's routing > following). That dynamic-bus-negotiation design was superseded by the GA fix > above; root `CLAUDE.md` is current and wins. ### Sampling modes — Gate vs Trigger, pitch engine, pitch envelope (S15/S16 — settled, landed) Daniel's directive (2026-07-26, verbatim): *"Sampling mode: Trigger vs Gate. Gate has an AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable start point, Gate has modifiable loop points too. In addition to amp env, there will be a pitch envelope/curve (AD?) which is off by default."* - **Gate — classic held note.** Note-on enters the amp envelope; note-off enters release; a sustain loop applies for held notes. Envelope is **AHDSR**: `0→1` over attack, hold at 1 over `holdFrames`, `1→sustain` over decay, hold sustain until 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). - **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/ classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear interp — resampling that couples pitch and duration; cheap, zero-latency, musically right for drums/one-shots. Preserve (duration-preserving): the read advances at the source rate while a pitch shifter transposes the output — musically right for tempo-locked loops/phrases; **the engine default leans Preserve** (fork S16-F1). 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. - **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 accepted property, not a defect." Root `CLAUDE.md`'s GA2 pass **eliminated** that onset latency (ring buffer primed with the actual upcoming source at note-on instead of zero-filled, so Preserve now speaks on frame 0, matching Varispeed) — a cold-started/un-pre-warmed shifter producing a click or smear remains a bug. - **S15/S16 stay channel-count-agnostic.** The mode/envelope logic is per-frame amplitude and read-rate, independent of the stereo channel dimension — any S15/S16 code that assumes a fixed (mono) channel count rather than operating per-frame pre-mix is a bug. - **S15/S16 are Tier 0–1 engine features, not Tier 2/3** — do not let the held Tier-2 feature list (velocity layers / round-robin / filter work) drive their build shape. ### Live parameter delivery — a knob moves the note already sounding (settled 2026-07-30) Daniel's ruling, verbatim: *"hell no, I was going to bring that up for the other envelopes. We must live compute, latching the parameters at note on is not acceptable. long term these will be automatable parameters."* It rejects the precedent, not one instance of it. - **Which controls are live is ONE decision, recorded in ONE place** — `isLiveDeckParam` and `liveCommitFor` (`ui/deck_groups`), whose header is THE home for which controls are live and why each exclusion is excluded — see there rather than restating the list here. - **Ownership sits ABOVE every snapshot.** `SampleData::live` is a NON-OWNING pointer to the one block the shell owns per instance. The member-ordering constraint that enforces it, and why, are recorded at `liveParams_` in `shell/instrument/reasampler_processor.h`. A drain voice tracking the knob is the DESIRED behaviour — it is the note the user is hearing. - **Null is the bare engine.** `live == nullptr` is byte-identical to the pre-live core, which is why `sampler_core`'s regression baselines needed no change. - **Observation is at block boundaries, never per frame.** `VoiceEngine` reads the seqlock once per `render()` and once per note-on; the per-sample path gained three predicted branches (the voice's filter-ramp check and each envelope smoother's active check), all false at rest, and no indirection. - **A fresh note SNAPS, a sounding one holds φ.** They are different entry points on purpose (`snapLive` vs `applyLive`): a voice that has rendered nothing has no phase to hold, and the φ rule reads its stage-0 position under a stale zero-length stage as a completed stage. One function serving both silently discarded every newly-dialled attack. - **The mid-stage rule is HOLD NORMALIZED STAGE POSITION** (Daniel's pick among six candidates): φ = elapsed/duration is held across a stage-time change, so the level is continuous by construction and the remainder takes its share of the new duration. Stated over normalized position rather than output level ON PURPOSE, so a per-segment curve exponent composes with it as a pure map of φ. Recomputing from absolute elapsed (which steps) is the rejected alternative — do not reintroduce it. - **Two genuine level steps are smoothed, not ruled away**: a sustain level moved while the voice holds it, and a stage duration dialled to exactly zero mid-stage. Both are absorbed by the envelope's own bounded offset smoother. ### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md) - **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target. - **The pure core stays REAPER-free *and* VST3-free.** Any VST3 or REAPER type leaking into the voice engine / envelope / keymap / repitch module is a bug (the D3 split). - **Channel mode is a performance choice, not a bank fact.** Never written to `Sample` or the bank. - **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 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. ### Parameter ownership and persistence (D-B) - **Key-tracking** — additive/version-bumped component state, default 100% (absent field on an older blob lifts to 100%, bit-identical playback). - **Preview velocity** — a per-instance utility setting for the Sample view's preview-trigger button (not a musical parameter of the capture); **persists across reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the extension's `persist` ext-state module (that would make it project-global rather than per-instance and leak an instrument concern into the extension's key space). - **Velocity curve** — the one non-back-compat surface in S-VIEW: an already-saved instance with no stored curve now plays every velocity at unity under the flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping — a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules). ## Modules ### `engine/` - 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. - `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. - `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`. - `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved). - `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…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. - `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. ### `ui/` - `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own. - `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack. - `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath. - `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins. - `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap. - **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one. - `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing. - `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); 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. ## 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. - **`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. - **Two superseded designs are called out in Invariants above**: the earlier Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth for both — do not reintroduce either superseded design. - **A filter envelope only advances while its depth is non-zero.** `tickFilterCutoff`'s exact skip at `modAmount == 0` skips the envelope tick along with the solve, so dialling depth up mid-note starts the envelope from the note's stage-0 position rather than from where it would have been. Its step smoother is frozen with it — an absorbed step sits in the offset and emits when depth is next dialled up (bounded, and scaled by a depth ramping from 0). Continuous either way (the contribution starts at 0), and keeping the skip is what holds the at-rest per-sample path byte-identical — but don't read a live depth move as "resuming" an envelope that was never running. - **A live edit leaves the snapshot's own `sample.play` stale, on purpose.** The block, not the snapshot, is the audio thread's source; a new voice latches the stale copy and is corrected by `snapLive` before its first frame. - **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep covers client-pixel widths (including multiples standing in for larger client areas); nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI scaling of the plugin window — which would resample the uniform integer key widths at the physical-pixel level — is unverified.