Files
reasampler/CLAUDE.md
T

242 lines
21 KiB
Markdown

# 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-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.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_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-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 / param / 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.