Files
reasampler/src/core/instrument/CLAUDE.md
T
daniel efd1e41f46 instrument: narrow the live-param publish lock to its own mutex, off the reload's decode
Knob-drag publishes no longer block behind a full reload's WAV decode — a dedicated livePublishMutex_ replaces reloadMutex_ for the seqlock's single-writer contract. Also fixes an editor comment overclaim and two doc restatements.
2026-07-30 21:54:20 -04:00

31 KiB
Raw Blame History

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 01 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 placeisLiveDeckParam 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 01'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.cppVoiceEngine: note routing, bounded-stealing allocation, user-parameterized voice count (132, default 16), VoiceMode Poly/Mono (last-note held-note stack, MonoTrigger Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main VoiceEngine — no dedicated PreviewCard; preview obeys polyphony/mono/voice-stealing/envelopes.
  • 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 FritschCarlson 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_bandsTHE 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.