Files
reasampler/CLAUDE.md
T
daniel bfaa0f2614 Report the instrument's automatable parameters to the host under a frozen id table, in signal-flow order, with real units
42 of 44 ids issued: pitch key-track and Trigger length stay reserved
pending a live path. Master gain reclassified Live — it never reloaded.
2026-08-02 15:14:16 -04:00

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-four 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-sdksdk/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.sdk
    

    The VST3 target (reasampler_vst) is gated on EXISTS .../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_fileversion_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-four 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/instrument/param/ the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category
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 bake/ / engine/ / map/ / note/ / param/ / 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:

  • peaks envelope compute (computeEnvelope / lastFrameAboveThreshold over full PCM): NO virtual dispatch, NO peaks interface, NO added header→TU indirection. computeEnvelope stays a free function on const std::vector<float>& so it inlines as today. Relocate + namespace only; never wrap in an abstraction.
  • Audition / preview: panel_audition may 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_lifecycle must not change the tick's branch shape.
  • JSON extraction is off all hot paths — safe to abstract freely.
  • FxBypassGuard runs 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:

  1. 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.
  2. Templates are the right tool for compile-time dedup — use them where earned. The LE byte-codec putLE/readLE is earned: compile-time dispatch, zero runtime cost, off the hot paths. The rect family is NOT: the types differ in name only, so one concrete ui::Rect — a template there would model nothing.
  3. 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_core split that would put virtual envelope tick()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.md already 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 is main.cpp. Every other .cpp includes reaper_plugin_functions.h without the define and gets extern declarations for the global API function pointers.
  • REAPER dlopen()s any reaper_*.dll|dylib|so found in UserPlugins/ and calls the ReaperPluginEntry export (produced by REAPER_PLUGIN_ENTRYPOINT). rec->GetFunc resolves API pointers; rec->Register plugs 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 in shell/actions/action_registry (ActionTableRow: suffix, phrase, flat function-pointer handler) — registration, hookcommand dispatch, 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:
    1. 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 via channelCommandId(suffix) and channelActionName(phrase) from app_version — the FOREVER-STABLE contract applies per channel (stable and beta each have their own permanent id family).
    2. rec->Register("gaccel", &accel) — puts the action in the Actions list.
    3. rec->Register("hookcommand", ...) — receives every action fired; claim only your own id, return false otherwise.
    4. On unload (rec == nullptr), mirror-unregister everything with the same strings prefixed by '-'.
  • Non-main sections use a different mechanism. gaccel_register_t carries no section field — command_id + gaccel can 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 a custom_action_register_t{uniqueSectionId, idStr, name, extra} under "custom_action"; it returns the command id, or 0 on failure (e.g. a duplicate idStr) — which the caller must tolerate rather than half-register. idStr must 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_t has no ACCEL, 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 a true return 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 -pcmsrc by name; custom_action itself 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::channelCount and the file's fmt written 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 at RENDER_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 into reaper_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 separate VoiceEngine, 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.