Files
reasampler/src/core/instrument/CLAUDE.md
T
daniel f39fb1b145 Γ-W1-T3: staged contour traces draw the curve their exponent defines
New pure curve_tessellate joins the overlay's node vertices through curveMap,
one sample per pixel column; the knot no longer floats off its own trace.
2026-08-02 13:47:19 -04:00

47 KiB
Raw Blame History

src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)

Scope

The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five 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, 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 the offline bake and any future editor of it read from, so they cannot diverge.
  • bake/ — the resample bake's pure half: the programmed note resolved to a frame window, the offline render over a voice engine built for that render alone, and the ratified post-bake reset. See bake/CLAUDE.md.
  • 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, cycling indefinitely until note-off, with a user-parameterized pre-seam crossfade at the reset (engine/loop/). 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; 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/ 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 — 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 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.

The envelope overlay — one graphical surface, every envelope (S-VIEW, extended)

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.

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; enforceGateUnavailableWhileDrawn (play_params.h) is the one enforcement of it, called by both resolvePlay (sample_map.cpp) on the way to the engine and the editor's applyControl, so the two callers cannot drift. The editor's Gate segment refuses and paints Disabled off the same splineActive 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 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)

  • 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 curves — three of them (amp, pitch, filter), all per-instance, edited from ONE deck group. The amp curve is 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. The pitch and filter curves are bipolar and off by default (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. Also the ONE home of the drawn-EG rule family — splineActive, effectivePlayMode, enforceGateUnavailableWhileDrawn and effectiveLengthFraction — all templated over the frames and seconds representations, so no consumer of either can re-read the raw fields instead.
    • 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. Documented ~600-line-ceiling exception (root CLAUDE.md structural heuristic 1): voice.h sits over the ceiling because advanceFrame's RT-inline constraint forbids the seam a split would need — a documented exception, not silent overshoot.
    • 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.
  • 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 monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. VelocityCurve is evaluated as ONE OR MORE FritschCarlson 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.
  • play_seconds — the stored, wall-clock-SECONDS value layer (PlaySeconds + AdsrSeconds / AhdSeconds / PitchEnvSeconds / FilterSeconds), header-only and split from sample_map so a consumer that only edits those values reaches them without the bank model and the WAV codec. resolvePlay, which turns them into the engine's frame domain, stays with the rest of the mapping.
  • component_state_io (core/instrument/map) — the ComponentState envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), 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), v14 the resample bake's Hold division. 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.
  • trigger_seam — the shared Trigger play-SPAN formula: how a %-length becomes the source-frame span the overlay draws over and the bake's window holds, threading startFrame correctly and clamping the fraction the same way Voice::start does (the engine evaluates the same formula inline rather than depending on map/). The spline fold every consumer must apply first — effectiveLengthFraction — is play_params.h's, beside the rest of that rule family. (Its fade frames↔fraction converters retired with the fade pair itself.)

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), and kEditorCeilingWidth, the floor's sibling window fact (the hard cap the floor may not exceed) — moved here from knob_deck.h since it is a window fact, not a deck one; the derivation identity against the deck's width budget stays in test_deck_groups.cpp, the one place that already includes both headers. 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 — bake Hold cell, bake, preview, velocity knob cell, 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. Every run member's width is RESERVED unconditionally, the Hold cell included — the only conditionally-drawn one, and the leftmost, so what its reservation buys is a title slot that does not re-measure when a loop is dialled in or out (sample_chrome.h records the cost). Also previewGlyph, the preview button's play triangle — three vertices for one filled-triangle draw, so the button's label needs no font metric and no image asset.
  • bake_hold — the Hold knob's value domain and nothing else: the knob's normalized [0,1] mapped onto the note-length ladder and back, ordered by LENGTH rather than by the ladder's presentation order. Split from sample_chrome on the same axis deck_values was split from knob_deck — that says where the cell is, this says what its position means.
  • 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, plus markerHandleRect — a top-strip grab tab distinct from a marker's full-height column, so two markers that share a frame stay independently grabbable (the column goes to the first in draw order; the tab, asked first, resolves the other).
    • 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_taper — THE norm↔value tapers every variable control shares, and the modifier vocabulary its drag surfaces read: the stage-time shifted-log (and kStageTimeMaxSeconds, the ONE home of the stage-time ceiling that envelope_overlay's kGateStageMaxSeconds and deck_values' kEnvTimeMaxSeconds alias), the centre-expanded semitone-depth map, DragModifiers/kFineDragScale/fineDrag, the UnitCategory axis, and the four whole-unit snaps Shift applies. Extracted from deck_values because it has THREE consumers in two dependency layers — the knob's needle (deck_values), the AHDSR schematic axis and its drag inverse (envelope_overlay/envelope_edit, which sit below deck_values), and the VST3 host's toPlain/toNormalized. Three functions that agree today is a defect, not an implementation choice; solving the include edge by copying the map is the specific mistake this exists to prevent. Both maps resolve their output onto a fixed decimal quantum, which is what makes "every default has an EXACT normalized preimage" a structural guarantee rather than a libm coincidence — the header states the argument; the converse round trip at an arbitrary norm is explicitly NOT required.
  • param_slider — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel. knobDragValue is the knob's grab-anchored absolute drag law and applies Ctrl's rate — but not Shift's snap, whose whole unit is a property of the control's unit category this module does not know.
  • 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. Carries a SECOND hit-test, hitTestKnobFace, resolved against the drawn CIRCLES rather than the cell: a double-click reset is aimed at a dial, so the label band and the cell margins must miss where a drag grab deliberately does not, and only a radial resolve can tell the inner curve dial from the outer ring it sits inside. The deck's width budget at the editor's floor — the row block, the spanning deck's reserve, and what drives the floor — is declared and reasoned at the constants themselves (knob_deck.h; the ceiling itself now lives in sample_bands.h as a window fact); every group's categorical row is deck_groups' deckRowFor. 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, where a rowToggle widens the GROUP and is charged against that budget — which is why the env decks' mode toggles ride the caption row. A group's cell run is a RESERVED WIDTH, not a fixed cell size: a -1 id reserves one cell's width without a cell, and the cells present divide the whole run between them at one uniform integer width (residue in symmetric end margins). That is what lets a mode flip drop controls from a face — Trigger's AMP and FILTER ENV lose their Sustain/Release stages — without either reflowing the deck or leaving dead slots in the box; a face with fewer controls simply gets roomier cells. Do not reintroduce fixed-width cells with blank slots.
  • deck_values — the deck's control-id ↔ parameter-set BINDING and its display units, split from the editor shell on the same axis deck_groups was split from knob_deck: deck_groups says which controls exist, this says what each one's value MEANS. Holds deckParamNorm / setDeckParam (the normalized ↔ stored-seconds/fraction/position binding and its clamps, over param_taper's maps), resetDeckParam (the double-click reset — the defaults are READ off a default-constructed PlaySeconds, so there is no second table of defaults to drift, and the value is COPIED rather than round-tripped: that taper bypass is mandatory and must never be "simplified" back into a norm round trip), deckParamUnit/snapDeckParamNorm (THE snap-unit table, and where each control's full scale enters — a whole DISPLAYED percent is a different norm step at 0..100 %, 0..200 % and ±100 %), and formatEnvTimeMs, the ONE time-constant formatter: every displayed time constant reads in ms, never seconds, so two stage times are comparable at a glance. A display-unit decision only — nothing about the stored representation changes. Links the header-only play_seconds, deliberately not sample_map: PlaySeconds is the whole of what a deck edits, and linking the mapping would drag the bank model and the WAV codec in behind it. The shell keeps only the controls the parameter set does not carry (key-track, voice count, master gain, preview velocity) and the labels for them.
  • 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. Spline points are excluded from param_taper's Shift/Ctrl modifier law like waveform markers are: a point is a normalized position with no displayed unit, and control-click there is already claimed by the hard/smooth toggle above.
  • 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.
  • curve_tessellate — the staged envelope's TRACE, split from envelope_overlay on the axis those two already have: that module decides where a node LANDS, this strokes the span BETWEEN two of them. Joins the non-knot vertices with the curve each stage's exponent defines, sampled one point per pixel column, at start + (end - start) * curveMap(phi) — the composition envelopes.h's four evaluators use, so a drawn stage and the sound it makes cannot diverge. Node vertices keep their exact integer coordinates (the handles are drawn on them); only the interior samples are sub-pixel. A neutral exponent or a zero level span emits the two endpoints and nothing between, which is the straight stroke drawn before curves existed, vertex for vertex.
  • 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

  • An AHDSR's overlay x-axis is schematic, not PCM-aligned, and it is not linear in seconds either — it does NOT line up with the waveform under it, and each of its four equal stage slots is filled by param_taper's own norm, so a node's position within its slot IS its knob's needle position. Two stages therefore cannot be compared by eye at a 10:1 ratio; the ms labels carry the number. Only a sustain-less AHD's x-axis is wall-clock/PCM-aligned and linear. Content-fit auto-scale and a minimum drawn stage width were both considered and REJECTED — the first moves the axis under the hand, the second decouples the drawn position from the value and breaks the drag inverse.
  • 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.
  • 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.