Cutoff-only re-solve (15.5 vs 56.9 ns/frame) makes the unquantized sweep affordable, replacing the 2048-step mod quantizer. Live-compute parameters remain blocked on a shell-architecture ruling.
27 KiB
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 +SampleDatabuild, the cross-artifactComponentStatecodec, 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 theshell/instrumenteditor shell, along with the VST3 processor,reaper_bridge,reasampler_embed, andvst_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 insrc/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 waypersistdoes (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 asprovenance). - 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 toSampleor 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.
Samplefield additions are additive and lossless. No existingSamplefield changes; noBankIndexbehavior 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; rootCLAUDE.mdis 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→1over attack, hold at 1 overholdFrames,1→sustainover decay, hold sustain until note-off,level→0over release.holdFrames == 0is 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)whereplayEnd = startFrame + round(lengthFraction·(frames − startFrame)); amplitude ramps0→1overfadeInFramesat the head and1→0overfadeOutFramesanchored toplayEnd; fades clamp sofadeInFrames + 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(clamped0 ≤ 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_Resampleris 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
(
peakSemitonesoverattackFrames, decaying to 0 overdecayFrames) 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 ofratio_; 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." RootCLAUDE.md's GA2 pass eliminated that onset latency (ring buffer primed with the actual upcoming source at note-on instead of zero-filled, so Preserve now speaks on frame 0, matching Varispeed) — a cold-started/un-pre-warmed shifter producing a click or smear remains a bug. - S15/S16 stay channel-count-agnostic. The mode/envelope logic is per-frame amplitude and read-rate, independent of the stereo channel dimension — any S15/S16 code that assumes a fixed (mono) channel count rather than operating per-frame pre-mix is a bug.
- S15/S16 are Tier 0–1 engine features, not Tier 2/3 — do not let the held Tier-2 feature list (velocity layers / round-robin / filter work) drive their build shape.
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
Sampleor the bank. - Do not spec Tier 2/3 from this directory. Tier 2 is held, Tier 3 is optional-forever; don't let their feature lists drive Tier 0–1's build shape.
Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
anchored to playEnd. The overlay is directly editable — draggable nodes
(SETTLED, S-VIEW-F2). Dragging a node and the existing sliders are two surfaces onto
one model: both read/write the same envelope fields of the one parameter set, so a drag
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
same per-param min/max the sliders enforce, so node-drag can never produce a param the
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
envelope_overlay and envelope_edit in Modules below.
Parameter ownership and persistence (D-B)
- Key-tracking — additive/version-bumped component state, default 100% (absent field on an older blob lifts to 100%, bit-identical playback).
- Preview velocity — a per-instance utility setting for the Sample view's
preview-trigger button (not a musical parameter of the capture); persists across
reloads via the instrument's own
ComponentState(envelope-bumped), never via the extension'spersistext-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/127mapping — a deliberate, Daniel-approved behavior change (seevelocity_curvein Modules).
Modules
engine/
- The engine is the
sampler_coreCMake 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), andSampleData(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 aVoicemember changes.FilterParamsstores the filter module's ownFilterSettingsby value rather than a parallel copy of its normalized positions.envelopes.h— the three per-frame evaluators (AdsrEnvelopeAHDSR,TriggerEnvelopefade shape,PitchEnvelopeAD offset), CONCRETE and fully header-inline. Never give them a common base or a virtualtick(): they are called per-voice-per-sample. The filter envelope is a SECONDAdsrEnvelopeinstance on the voice, not a fourth class.voice.h/voice.cpp— one voice. The per-SAMPLE render half (advanceFrameand 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 ownVoiceFilterand filter envelope, run between the pitch stage and the amp multiply — seeengine/filter/CLAUDE.md.voice_engine.h/voice_engine.cpp—VoiceEngine: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16),VoiceModePoly/Mono (last-note held-note stack,MonoTriggerRetrigger/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 mainVoiceEngine— no dedicatedPreviewCard; 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 inprocess().velocity_curve— pure velocity→amp transfer curve:VelocityCurveevaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]).eval(velocity)called once per note-on.flat()default (y=1, every velocity→unity) replaces the prior fixedvelocity/127path — 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 theSampleDatabuild. Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates insrc/(Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plainSampleData.component_state_io(core/instrument/map) — theComponentStateenvelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), split out ofsample_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'sinstrument_dropand 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 theGetProjExtStateint 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, threadingstartFramecorrectly through pack and unpack directions.
ui/
editor_geometry(core/instrument/ui) — the shared geometry VOCABULARY every instrument UI module speaks: thecore::ui::Rectalias,contains(), andOverlayArea(a one-fieldRectwrapper, no implicit conversion fromRect). Header-only (an INTERFACE CMake target), so it carries no layout of its own.sample_bands— THE band-stack allocator, and the only module that owns the Sample face's vertical inventory — includingkEditorMinWidth/kEditorMinHeight, the editor's client-area floor, which IS its default size (the shell'scheckSizeConstraintand openingViewRectboth 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 (waveformLanestakes a resolvedLaneSplit, not a raw bool — onlywaveformSurfacefolds 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:waveformSurfaceresolves 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, andlaneEnvelopesplits 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 standalonewaveformOverlayArea(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:overlayis the distinctOverlayAreatype (editor_geometry), notRect, so every overlay-consuming API (frameToX/markerAtPoint/resolveDragFrame,envelope_edit'snodeAtPoint/resolveNodeDrag,envelope_overlay'sbuildEnvelopePolyline) rejects a lane rect at compile time rather than silently accepting one.
- Overlay contract (consumed by later waveform work).
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 overcapture_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 ofaction_bar/param_slider; no LICE or REAPER types.deck_groups— WHICH groups the Sample face's deck carries, split fromknob_deck's HOW they lay out: theDeckParamcontrol-id space (the editor'sParamControlis an alias of it), theDeckGroupIdlist,sampleDeckGroupsin signal-flow order (pitch → filter → amp, then voice/master), and the deck's bipolar-knob law. ReadsPlayModefor the AMP group's Gate/Trigger face, which is why this and notknob_deckis 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 ofoverflow_menu; no LICE or REAPER types.envelope_overlay— pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read fromenvelope_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/yinside the rect). Shares theEnvNode/AmpEnvelope/timeToX/levelToYvocabulary withenvelope_editso 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 fromenvelope_edit.h):nodeAtPointresolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break);resolveNodeDragmaps a pixel delta since grab to a newAmpEnvelope, 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 ofcard_drag/waveform_view; the inverse ofenvelope_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_seamowns this formula; do not copy the fields directly. param_slider's linear slider rows are retired on the parameter surface — per rootCLAUDE.md's FB2 note, theKnobprimitive (the knob-deck grammar) is now the only live consumer of that half ofparam_slider. Don't assumeparam_slider's SLIDER row type is still drawn.- The engine's per-sample path is inline ON PURPOSE.
Voice::advanceFrameand the three evaluators inenvelopes.hlive in headers soVoiceEngine::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 virtualtick(), 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.mdis the current source of truth for both — do not reintroduce either superseded design. 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 implementsIPlugViewContentScaleSupport, so host-side DPI scaling of the plugin window — which would resample the uniform integer key widths at the physical-pixel level — is unverified.