21 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Repo identity and current state
ReaSampler is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (reaper_reasampler) and ReaSampler 9000, a Windows-only VST3 sampler instrument (reasampler_9000.vst3, core/instrument/ + shell/instrument/, second CMake target reasampler_vst, gated on the vendored vendor/vst3sdk submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: core/ never includes REAPER or VST3 SDK types, shell/ is where those hosts are actually touched, app/ is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against vendor/reaper-sdk/sdk/reaper_plugin_functions.h before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-three per-directory src/**/CLAUDE.md files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in docs/ARCHIVE.md; current work lives in docs/COMPLETED.md, docs/TODO.md, and docs/TODO-1.0.md — see "Project docs" below.
Settled decisions
- Capture modes: offline render (deterministic, the default) AND realtime record (for hardware / performed FX). The two backends deliberately share no interface — their lifecycles genuinely differ (offline is synchronous, realtime is async begin/tick/abort) — but both produce identical bank entries.
- Bank scope: per-project, travels with the
.rpp. Files live in a project-relative subfolder; the index persists in project ext state. No absolute paths anywhere in the index. - Material: must handle full-mix/stem bounces, chops/one-shots, and single-cycle/wavetable grabs equally. That means exact sample-accurate bounds, explicit tail control, correct channel handling (the exact-bounds channel rule under Precision invariants), and loop/zero-crossing handling all matter from day one.
One-time submodule setup
git submodule update --init
Vendors three submodules (see .gitmodules):
-
vendor/reaper-sdk—sdk/reaper_plugin.h,sdk/reaper_plugin_functions.h, SWELL headers -
vendor/WDL— WDL utilities and the SWELL cross-platform Win32 layer -
vendor/vst3sdk— Steinberg VST3 SDK (Windows-only; requires a nested init after the top-level init):git submodule update --init vendor/vst3sdk cd vendor/vst3sdk && git submodule update --init pluginterfaces base public.sdkThe VST3 target (
reasampler_vst) is gated onEXISTS .../pluginfactory.cpp— configure quietly omits it if the slice is absent.
Build and test
cmake -B build -S .
cmake --build build
ctest --test-dir build
On a multi-config generator (Visual Studio, Xcode) the bare ctest command above
reports every test as "Not Run" — add -C Debug (or whichever config was built) to
resolve the test executables. Single-config generators (Ninja, Make) need no such flag.
On a multi-config generator, cmake --build build with no --config builds Debug —
there is no CMAKE_BUILD_TYPE, no CMAKE_CXX_FLAGS, and no IPO/LTO setting anywhere in
the build, so nothing is optimized or inlined at that default. The performance
guardrails and structural heuristics below (header-inline hot paths, "no LTO
configured") presume an optimizing build. Shipping, installing, or judging
performance requires the Release config explicitly:
cmake --build build --config Release
ctest --test-dir build -C Release
Every pure module has a corresponding <module>_tests executable target that runs without REAPER or a DAW. Targets are declared per directory: each src/**/CMakeLists.txt owns its own libraries and their test targets, added via add_subdirectory from the root, which keeps only repo-global settings (version, channel, vendor paths). cmake/reasampler_targets.cmake holds the two shared declaration helpers. The two loadable-module targets are reaper_reasampler (the REAPER extension .dll/.dylib/.so) and reasampler_vst (the VST3 instrument; Windows-only, omitted if the vendor/vst3sdk slice is absent). The sample_usage_tests executable target runs the pure unit tests for sample_usage (no REAPER, no DAW).
Beta channel build
To build the fully isolated beta binary (reaper_reasampler_beta), pass the channel flag at configure time:
cmake -B build-beta -S . -DREASAMPLER_CHANNEL=beta
cmake --build build-beta
The flag threads through configure_file → version_generated.h and fans out via app_version into the binary name, ext-state namespace ("reasampler_beta"), command-id prefix (CEREBELLUM_REASAMPLER_BETA_), action-name prefix ("ReaSampler beta: "), dock ident, and version display (the configured version string with a -beta suffix appended). The default build (no flag) is byte-identical to the stable identity.
The VST3 target forks identically: REASAMPLER_CHANNEL=beta produces reasampler_9000_beta.vst3; the default produces reasampler_9000.vst3. The beta VST pairs only with the beta extension — each channel carries its own per-channel VST3 class UID, preventing a saved instance from rebinding across channels.
macOS / Linux: SWELL dialog resources
src/resource.rc must be pre-processed by SWELL's resgen once per platform:
php vendor/WDL/WDL/swell/swell_resgen.php src/resource.rc # macOS; Linux reuses the output
Add the generated file to the appropriate APPLE / Linux target_sources block in src/app/CMakeLists.txt. The SWS extension build is the canonical reference for this step.
Install / reload
There is no hot-reload. Copy the Release build's binary (build/Release/ on a multi-config generator — not the default Debug/ output) into REAPER's UserPlugins/ folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only.
Architecture: the load-bearing split
core/ holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding <module>_tests target that runs without a DAW. shell/ holds the REAPER/host-facing shells — where those SDK types are actually touched. app/ is the extension entry point. Each of the twenty-three directories below carries its own CLAUDE.md with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
src/app/ |
REAPER extension entry point |
src/core/audio/ |
pure audio-data math |
src/core/capture/ |
pure logic behind the capture pillar |
src/core/instrument/ |
pure VST3-instrument core (bake / engine / map / note / ui) |
src/core/instrument/bake/ |
the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
src/core/instrument/engine/filter/ |
pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each Voice between the pitch and amp stages |
src/core/instrument/note/ |
the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
src/core/json/ |
the hand-rolled JSON lexical layer |
src/core/model/ |
the pure bank/sample index and its multi-bank container |
src/core/reclaim/ |
pure prune orphan computation |
src/core/tracking/ |
the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add |
src/core/ui/ |
pure UI geometry, palette, and interaction-decision modules |
src/core/util/ |
small shared pure utilities |
src/core/version/ |
version/channel identity |
src/core/view/ |
pure Design View mode model |
src/core/wire/ |
pure ext-state and wire-format codecs, cross-artifact contracts |
src/shell/actions/ |
bindable REAPER actions, drag/drop shells, ingest |
src/shell/bank_ops/ |
promptless bank-mutation verbs |
src/shell/capture/ |
REAPER-facing capture backends and action bodies |
src/shell/instrument/ |
ReaSampler 9000 VST3 shells |
src/shell/panel/ |
the docked bank-panel shell + the shared LICE draw kit |
src/shell/persist/ |
project ext-state persistence, prune filesystem I/O, usage scan |
src/shell/view/ |
Design View mode application shell |
Directory and namespace layout
The top-level split is by the pure/shell discipline: core/ never includes REAPER or VST3 SDK
types; shell/ is where those host types are actually touched — the discriminator is "may this
file touch a host type, REAPER or VST3 SDK." Subsystem directories sit beneath core/ (see the
table above); core/instrument/ further subdivides into engine/ / map/ / note/ / ui/. Namespaces
mirror directories — reasampler::<subsystem> for core/, house style for shell/. app/ holds
main.cpp only: API-pointer ownership, ReaperPluginEntry, and dispatch.
For the module list within any one directory — what lives there, its invariants — open that
directory's own CLAUDE.md rather than looking here.
Performance guardrails (HARD CONSTRAINT — Daniel's non-negotiable)
The reorg must cost zero runtime. The two hot paths must keep their exact call/inline shape; the following are acceptance criteria on every point:
peaksenvelope compute (computeEnvelope/lastFrameAboveThresholdover full PCM): NO virtual dispatch, NOpeaksinterface, NO added header→TU indirection.computeEnvelopestays a free function onconst std::vector<float>&so it inlines as today. Relocate + namespace only; never wrap in an abstraction.- Audition / preview:
panel_auditionmay be its own TU, but the call stays a direct call-through, not virtual. - Realtime-capture tick: keep the idle fast-path a single pointer test;
realtime_lifecyclemust not change the tick's branch shape. - JSON extraction is off all hot paths — safe to abstract freely.
FxBypassGuardruns per-capture, not per-frame — keep it stack RAII; never heap-allocate or virtualize it.
Net: every recommended split falls on a cold path or preserves call/inline shape on the two hot ones. A split that would add a hot-path indirection is out of scope — rework it or drop it.
Structural heuristics (Daniel, 2026-07-28 — acceptance criteria phase-wide)
Three heuristics postdate the original framing and bind every wave. They generalize the three-hot-path performance guardrail above — they do not replace it:
- More directories is a must; more files is good; ~600-line file ceiling. SRP applies to
namespaces, encapsulation, and file organization alike. The ceiling is the bar, the audit's
named seams are the method: a file landing over ~600 needs a responsibility seam, not an
arbitrary bisection (bisection-to-hit-the-number is rejected). A documented hot-path
exception (
sampler_core.cpp) is legitimate; silent overshoot is not. - Templates are the right tool for compile-time dedup — use them where earned. The LE
byte-codec
putLE/readLEis earned: compile-time dispatch, zero runtime cost, off the hot paths. The rect family is NOT: the types differ in name only, so one concreteui::Rect— a template there would model nothing. - SOLID is great, but saved CPU is better. No dispatch-stack blowouts anywhere — not just
the three named hot paths; prefer static polymorphism where the types are compile-time-known.
A by-class
sampler_coresplit that would put virtual envelopetick()s on the per-voice-per-sample path is exactly the blowout this forbids.
Comment conventions (Daniel, 2026-07-29 — driving the tree-wide comment-reduction pass)
Comments carry why, and context where non-obvious — never what the code already says.
- File headers stay brief (~5 lines); no titled prose sections inside them.
- Don't restate invariants a directory's own
src/**/CLAUDE.mdalready owns — those files are the home for area invariants. - No wave/ticket/milestone IDs in comments. A one-line "do not reintroduce X" warning is fine without the ticket number.
- Keep: RT-safety, allocation, and threading constraints; SDK facts confirmed by experiment; compressed regression history; format/version-ladder semantics; warnings against a plausible-but-wrong change.
- When unsure whether a comment is load-bearing, keep it.
REAPER extension contract (src/app/main.cpp)
- Exactly one translation unit defines
REAPERAPI_IMPLEMENT— that ismain.cpp. Every other.cppincludesreaper_plugin_functions.hwithout the define and getsexterndeclarations for the global API function pointers. - REAPER dlopen()s any
reaper_*.dll|dylib|sofound inUserPlugins/and calls theReaperPluginEntryexport (produced byREAPER_PLUGIN_ENTRYPOINT).rec->GetFuncresolves API pointers;rec->Registerplugs extension callbacks in. - Action registration pattern (preserve this for all new actions). Since Q-W6,
main.cpp's own action family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is driven by a data-driven table inshell/actions/action_registry(ActionTableRow: suffix, phrase, flat function-pointer handler) — registration,hookcommanddispatch, and the unload mirror-unregister all iterate that SAME table, so adding an action touches one table row rather than three separate mechanisms. The other action families (shell/actions/design_view_actions,bank_actions,prune_action) register through their own TUs the same way. The per-step contract below is unchanged:rec->Register("command_id", (void*)"STABLE_FOREVER_STRING")— mints a persistent command id. Never change this string after shipping; user keybindings key off it. Since Phase V (V4), ids and display names are composed viachannelCommandId(suffix)andchannelActionName(phrase)fromapp_version— the FOREVER-STABLE contract applies per channel (stable and beta each have their own permanent id family).rec->Register("gaccel", &accel)— puts the action in the Actions list.rec->Register("hookcommand", ...)— receives every action fired; claim only your own id, returnfalseotherwise.- On unload (
rec == nullptr), mirror-unregister everything with the same strings prefixed by'-'.
- Non-main sections use a different mechanism.
gaccel_register_tcarries no section field —command_id+gaccelcan only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register acustom_action_register_t{uniqueSectionId, idStr, name, extra}under"custom_action"; it returns the command id, or 0 on failure (e.g. a duplicateidStr) — which the caller must tolerate rather than half-register.idStrmust be unique across all sections, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships.custom_action_register_thas noACCEL, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through"hookcommand2"(bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)) —"hookcommand"runs for the main section only. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (hookcommand2's doc says atruereturn prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with"-custom_action"[verify — DAW](the header confirms the-prefix for "most" registration types and spells out only-pcmsrcby name;custom_actionitself is unconfirmed) and"-hookcommand2".
Product design docs
docs/product/ holds the product-design reasoning behind each phase — the "why we chose this" that predates the spec. They are large; grep for the cited section rather than reading a file whole. docs/cmake-cheatsheet.md is a standalone build-system reference.
Files: capture-tail.md, code-organization.md, design-view.md, midi-playback.md, multi-bank.md, provenance.md, removal-and-prune.md, versioning-and-release.md, visual-design-language.md.
Project docs
Plan-style docs live under docs/:
docs/ARCHIVE.md— pre-1.0 history, rarely read, not a source of context for current work.docs/COMPLETED.md— 1.x landed items.docs/TODO.md— deferred follow-ups with recorded rationale.docs/TODO-1.0.md— the raw 1.x work list, not yet structured into a plan.
The load-bearing principle
Capture and placement are separate acts. Capturing audio writes a file to the bank and adds an index entry. It never puts an item in the arrange view. Placement is a distinct, on-demand action (insert module / InsertMedia). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and must be rejected in review.
Precision invariants — required before any feature ships
- Null test: a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per
docs/product/provenance.md— manual verification only.) - Bit-identical repeats: identical offline capture requests produce identical files.
- Non-destructive: capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
- Exact bounds: no rounding of the requested range; no added silence unless a tail is explicitly requested; no lossy channel fold — summing or averaging differing channels is forbidden. The one permitted collapse is lossless: a new capture whose channels are bit-identical per frame (float bit patterns, never an epsilon) lands as a 1-channel file, with
Sample::channelCountand the file'sfmtwritten together so the two can never disagree. Frame count, sample rate and bit depth are untouched by it. Never retroactive — existing entries and files are never rewritten — and ingest is excluded, because an imported file is the user's bytes, not our capture. The superseded wording ("channel count preserved") was already untrue in the other direction: a mono source renders atRENDER_CHANNELS = 2.[verify — DAW]"lossless" here is a file-bytes property; whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a dual-mono 2-channel item (pan law, mono spread) — the null test's actual playback-chain property — is unconfirmed. - Relative paths only in the persisted
BankIndex. - Capture FX scope: two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
The resample bake — the one crossing from instrument into bank
A click inside the ReaSampler 9000 editor bakes the dialed sound into a bank capture. The split is: the instrument renders, the extension banks. The instrument produces the audio on its own voice path in its own process (so the bake is the object code that made the sound the user approved, immune to engine-version skew between the two artifacts), stages it outside the bank folder, and invokes ONE extension action over the VST3 host bridge; the extension lands it and answers over the same per-instance key, synchronously, inside that call. Consequences that bind:
- The extension's link graph does not gain the voice engine.
sampler_core/pitch_shift/ the filter are NOT linked intoreaper_reasampler— a link edge to any of them means the design drifted back to an extension-side render. - No arrange mutation and no deletion. The bake writes a file plus an index entry, like every other capture. "Replace" means the bank entry now denotes the recapture; the superseded file survives on disk until a prune reclaims it — the iterate loop's recovery floor.
- The bake adds nothing to
process(). It renders on the UI thread over a separateVoiceEngine, with the live-parameter block detached.
Non-goals / guardrails
- No auto-insertion of captures into the arrange (see the load-bearing principle).
- Do not depend on REAPER's peak API for thumbnails; compute from the captured file.
- Do not silently time-stretch on insert; conform is opt-in.
- Do not trust cited REAPER API names blindly — verify against the SDK header.