Files
reasampler/src/core/instrument/CLAUDE.md
T

24 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.

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, 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.
    • 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.
    • 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.
    • 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…v8), 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).
  • 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: 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 + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder.
  • keyboard_strip — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver.
  • 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.
  • 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.