From 87d7ceb066639600fe6b31f6112124009c663871 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 31 Jul 2026 07:02:54 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20close=20out=20=CE=9E-W1-T1,=20=CE=9E-W1?= =?UTF-8?q?-T2=20and=20=CE=98-W3-T1=20into=20COMPLETED;=20map=20the=20note?= =?UTF-8?q?=20directory;=20document=20the=20Release=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 21 +- docs/COMPLETED.md | 85 ++++++ docs/PLAN.md | 511 +++++----------------------------- src/core/instrument/CLAUDE.md | 7 +- 4 files changed, 168 insertions(+), 456 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9fbfdf..0c4415b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **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-one 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. +Per-module detail — what each file owns, its invariants — lives in the twenty-two 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 @@ -46,6 +46,16 @@ On a multi-config generator (Visual Studio, Xcode) the bare `ctest` command abov 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 `_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 @@ -69,19 +79,20 @@ Add the generated file to the appropriate `APPLE` / Linux `target_sources` block ### Install / reload -There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folder (Options → Show REAPER resource path) and restart REAPER. Extensions load at startup only. +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 `_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-one 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. +`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_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-two 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 (engine / map / ui) | +| `src/core/instrument/` | pure VST3-instrument core (engine / map / note / ui) | | `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 | @@ -104,7 +115,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde 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/` / `ui/`. Namespaces +table above); `core/instrument/` further subdivides into `engine/` / `map/` / `note/` / `ui/`. Namespaces mirror directories — `reasampler::` for `core/`, house style for `shell/`. `app/` holds `main.cpp` only: API-pointer ownership, `ReaperPluginEntry`, and dispatch. diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index e8ba2eb..1f8cdb6 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -228,3 +228,88 @@ uniform key widths, note-name tooltips, root displayed and settable. carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it. - A stale-hover latch was fixed across **all** drag kinds and both drag-termination paths, wider than the strip work that surfaced it. + +### Θ-W3-T1 — live-parameter-delivery + +Continuous playback controls are now delivered live to sounding voices instead of being +latched at note-on. New `src/core/instrument/engine/live_params.{h,cpp}` holds a +seqlock-published `LiveValues` block owned at **processor-instance scope**, above +`LoadedInstrument`, so `live_` and `draining_` observe the same one (a drain-slot voice +tracks the knob, which is the desired behavior). `foldLive(const PlayParams&)` is the +single derivation from the value type; `PlayParams` stays a plain copyable value type. + +**Daniel's two decisions, both implemented:** +- **Reload tier = Grouping B.** Continuous knobs live (filter cutoff/Q/morph/drive/mod + amount/key-track; every stage time and level on all three envelopes). Root note, loop + span, and start frame still trigger a full reload. +- **Mid-stage rule = candidate (iv), hold normalized stage position.** φ = + elapsed/duration held fixed across a duration change, then advancing at + 1/newDuration — expressed over normalized position specifically so Θ-W3-T2's + per-segment curve exponent composes with it. + +**Deviations from spec:** +- **Trigger's %-length and fades are NOT live** — they are baked into `SampleData` at + build, so reload is the only tier that can deliver them. Consequence: a Trigger-mode + instance gets zero live amp delivery until Θ-W3-T2 folds the fade pair into the AHD. + The five non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`, + `kTrigFadeIn`, `kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`, + now their single home. +- Open question 3 resolved as **F2 + seqlock**; open question 4 (sub-block resolution) + was not built but not foreclosed — the writer interface assumes no UI thread; open + question 5 verified — a live edit still persists, `commitLive` keeps the + `setInstrumentParams` write. +- A filter envelope only advances while its depth is non-zero (the exact-skip at + `modAmount == 0`), which is what keeps the at-rest path byte-identical. + +### Ξ-W1-T1 — tracking-consolidation + +Consolidates the provenance/usage territory into one system: the retired +`owned_manifest` gives way to a new `src/core/tracking/` directory holding +`origin_ledger` (the record family — `OriginRecord`/`OriginKind`, the insertion-ordered +`OriginLedger`, its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion` +load classification) and `tracking_authority` (the one decision surface: +`pruneProtection` and `tiedUsageExists`). Both prune's protected set and the resample's +replace-vs-add decision are computed from one borrowed `TrackingState`, so the two +safety-critical consumers cannot drift apart. `isAbsolutePath` was hoisted out to a new +`src/core/util/relative_path.h`, shared with `bank_model`'s `Sample.relativePath`. + +**Deviations from spec:** +- The deferred persisted-instance-identity fix was **not** folded in — open question 5 + resolved as "restate the deferral." `docs/TODO.md` already carries the sharpened + rationale (the session-epoch candidate and its sibling-drop flaw); not duplicated here. +- `sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the + *decisions*, not the codecs. +- A realtime record interrupted by a project switch strands an untracked WAV in the old + project's bank folder. Resolved as document-don't-delete (prune is the exclusive + deletion authority); `docs/TODO.md` carries the entry. +- `PruneReport` fields were renamed; a malformed ledger is now reported as a distinct + blocker with its own recovery instructions. + +### Ξ-W1-T2 — note-program-model + +Lands the programmed-capture-signal model as a new pure module directory, +`src/core/instrument/note/` — a fourth peer of `engine/`/`map/`/`ui/` under +`core/instrument/` — holding `musical_division` (the 1/64–64/1 ladder with +dotted/triplet multipliers, the 39-entry picker order), `tempo` (validated BPM plus +every beats↔seconds↔ms conversion), and `note_program` (`Velocity`, the denominated +`OffsetAmount`, the anchored `StartOffset`/`EndOffset`, the `NoteProgram` record, and +`resolveNote`). + +**Deviations / resolutions from spec:** +- Open question "negative offsets" resolved: both directions are legal and the sign is + uniform (positive is later in time); only an *inverted* window is refused, reported + via `ResolvedNote::windowCollapsed`. +- Open question "denomination seam" confirmed: note length is musical-division-only; + the ms/beats duality belongs to the offsets alone. An offset stores the denomination + it was **entered in**, deriving the other view on demand, so a beats offset follows a + tempo change and a ms offset holds still. +- Module name/location resolved as `src/core/instrument/note/` — three modules, not + one, with the layering enforced by the CMake link line. +- **Beyond spec:** every value type closes its domain at construction behind a single + normalizing door (`makeDivision`, `offsetOf`, `Tempo::fromBpm`, `Velocity::of`), with + private value constructors. Consequence: `resolveNote` needs no failure path and + `ResolvedNote` no validity flag, because every returned field is finite for every + constructible program and tempo. Junk detection is relocated to the future codec, + which sees both the bytes it read and the value construction produced. `NoteProgram` + deliberately carries no MIDI note number — render pitch is deferred to Ξ-W2 as an + additive field. diff --git a/docs/PLAN.md b/docs/PLAN.md index 470a858..0c217dd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -28,21 +28,19 @@ transliterate: **Θ → `th`**, **Ξ → `xi`**. So Θ-W1-T1 dispatches into ## Decision state -**Two questions in this plan await a Daniel decision, and both are in Θ-W3-T1.** -Everything carried forward from `TODO-1.0.md` is still classified **[verify]** -(answerable by reading code or running the DAW) or **[propose]** (a design call made at -implementation review with a proposal, not a Daniel call); that classification is -preserved per question, attached to the track that will answer it. +Everything carried forward from `TODO-1.0.md` is classified **[verify]** (answerable by +reading code or running the DAW) or **[propose]** (a design call made at implementation +review with a proposal, not a Daniel call); that classification is preserved per +question, attached to the track that will answer it. -Θ-W3-T1 (`live-parameter-delivery`) is the exception, and it is an honest one: it is not -from `TODO-1.0.md` — it arose from Θ-W2-T1's implementation review — and it carries two -genuine **[Daniel]** questions that no amount of code-reading answers. **Which edits stop -triggering an instrument reload** is an edit-model call about the product's shape, and -**what a stage-time change does to a voice already inside that stage** is sound-defining. -Both are stated in that track with candidates laid out and neither pre-picked. The track -should not be dispatched at full scope until they are answered; a reduced-scope fallback -is named there so the track is not hard-blocked if Daniel would rather rule after seeing -the mechanism work. +Θ-W3-T1's two genuine **[Daniel]** questions — which no amount of code-reading could +answer — are both ruled on and the track has landed; see `docs/COMPLETED.md` for the +full narrative. **Reload tier = Grouping B** (continuous knobs live: filter cutoff/Q/ +morph/drive/mod amount/key-track, every envelope stage time and level; root note, loop +span, and start frame still trigger a full reload). **Mid-stage rule = candidate (iv), +hold normalized stage position** (φ = elapsed/duration held fixed across a duration +change, then advancing at 1/newDuration). No other track in this plan currently carries +an unanswered **[Daniel]**-class question. ### Flagged for awareness — not blocking, but decision-grade @@ -55,19 +53,6 @@ the mechanism work. deferred relay shape; see that track. Consequence to hold: if every candidate fails verification, item 15's "one click from inside the VST" framing is what gives, not the read-only invariant — the fallback is a bindable extension-side action. -2. **Item 17's "100% robust" and `docs/TODO.md`'s deferred persisted-instance-identity - fix sit one day apart and point opposite ways.** On 2026-07-28 Daniel accepted - shipping the safe-but-incomplete usage tracking and deferring the persisted-nonce - fix; on 2026-07-29 he required the tracking be "consolidated and made 100% robust." - My reading: those do not conflict — "robust" is a *safety* strength claim (no - destructive act follows from ambiguity), while the deferred wart is a *completeness* - one (prune stops reclaiming after a reopen; the bank folder grows, nothing is lost). - Ξ-W1-T1 carries this as a [propose] question: fold the deferred fix in, or restate - the deferral explicitly in `docs/TODO.md` terms. I am flagging it because the - consolidation is the natural moment to do it, not because it blocks. -3. **Phase Ξ Wave 1 is concurrency-safe with Phase Θ** (from Θ-W2 onward). See - "Running Θ and Ξ concurrently" below. Whether to spend a specialist that way is a - scheduling call, not a plan decision. ## Phase-wide acceptance criteria @@ -204,10 +189,12 @@ rework. T1 first means every parameter T2 introduces is authored into the delive mechanism from the start. The cost of the ordering is honest and worth stating: T2 is the wave's user-visible payload and T1 delays it by one track. -T1 must specify its mid-stage rule in terms that survive T2 — see that track's open -question 2, whose candidates are deliberately expressed over *normalized stage position* -rather than over output level, because T2's exponent is a pure map of that position and -must compose with the rule rather than invalidate it. +T1 has landed and specified its mid-stage rule in terms that survive T2: **hold +normalized stage position** — φ = elapsed/duration held fixed across a duration change, +then advancing at 1/newDuration (Daniel's pick among the candidates T1 laid out; see +`docs/COMPLETED.md`). It is expressed over *normalized stage position* rather than over +output level specifically so T2's exponent is a pure map of that position and composes +with the rule rather than invalidating it. Beyond the ordering, the W3 collision is the phase's densest, which is why neither track splits further: the envelope parameter model, the per-sample envelope evaluation, the @@ -219,247 +206,30 @@ forward/inverse map pair. The serialization is the correct answer. #### Θ-W3-T1 — `live-parameter-delivery` -**Goal.** Retire note-on latching as the delivery model for continuous playback controls: -a knob moved while a note is sounding changes *that* note, not merely the next one. -Establishes the live-parameter block and its publish path, splits the editor's commit -routing so a parameter edit stops rebuilding the instrument, and makes reachable a -property the filter DSP was deliberately built with and that nothing currently exercises. - -**Consolidates:** nothing from `TODO-1.0.md`. This track carries no item number — it -arose from Θ-W2-T1's implementation review. See "Work in this plan that is not one of the -seventeen" below the traceability table. - -**Origin — Daniel's ruling, recorded verbatim** (2026-07-30, on the filter's latched -cutoff): - -> *"hell no, I was going to bring that up for the other envelopes. We must live compute, -> latching the parameters at note on is not acceptable. long term these will be -> automatable parameters."* - -The ruling rejects the **precedent, not one instance of it.** Every playback parameter is -latched into the `Voice` at note-on today — the filter (Θ-W2-T1), the amp AHDSR, and the -pitch envelope alike. It also silently defeats a property that was built on purpose: -`src/core/instrument/engine/filter/CLAUDE.md` records that `prepare()` does **not** clear -state precisely so a live parameter move glides rather than clicks. Nothing reaches it. - -**The architectural finding — read this before scoping the track.** A staff-engineer -attempted the live-cutoff fix inside Θ-W2-T1 and correctly stopped. The fix is **not -local to the engine:** - -- An editor knob release calls `commitAndReload()` → `reloadInstrument()`, which re-reads - the bridge, **re-decodes the WAV from disk**, builds a fresh `SampleData` + - `VoiceEngine`, and atomic-swaps it into `live_`. -- The displaced instrument moves to `draining_`, where already-sounding voices keep - rendering **their own frozen `SampleData`**. New note-ons route only to `live_`. -- Therefore a `Voice` that read `sample_->play` per frame instead of latching would - **still** not move a sustaining note: that note lives in a different snapshot, and that - snapshot is by design never updated again. - -Two consequences bind the whole track. The live-parameter block's ownership must sit -**above** `LoadedInstrument` so `live_` and `draining_` observe the same one — a block -owned by a snapshot reproduces the defect exactly. And the set of edits that trigger a -reload has to shrink; that is the open product question below. - -**Three commit tiers already exist. The work is reassigning edits across them, not -inventing a taxonomy.** - -1. **Full reload** — `commitAndReload()` → `reloadInstrument()`: bridge read, WAV - re-decode, fresh engine, snapshot swap. Today: every parameter edit. -2. **Engine rebuild** — `rebuildVoiceEngine()`: rebuilds around the already-decoded - `SampleData`, no disk, same drain-slot swap. Today: voice count, voice mode, mono - trigger. -3. **Live** — `masterGain_`: a lock-free atomic the audio thread reads per block and - ramps toward per sample (`gainCurrent_` / `gainRampStep_`), no rebuild, no snapshot. - Today: master gain alone. - -Tier 3 is the shape this track generalizes, and it is a **house precedent already -shipped and already zipper-free** — not a pattern imported from elsewhere. Read it before -designing the block. - -**Behavior — what must become live.** Every continuous user-facing playback control: - -- **Filter** — cutoff, Q, morph, drive, mod amount, key-track. -- **All three envelopes** — every stage time and every stage level, on the amp AHDSR, the - pitch envelope, and the filter AHDSR alike. - -**Behavior — what stays latched per note, where making it live is a bug.** These are -facts about the note event, not controls, and a design that "generalizes" them into the -live block has misread the ruling: - -- **Velocity**, and everything derived from it — in particular the velocity-curve - evaluation result (`velocityGain_`, `filterVelOffset_`), which is evaluated once in - `Voice::start` on purpose. -- **The note number**, and the pitch ratio derived from it against the root note - (`baseRatio_`). Moving a root-note or key-track control must not retune a sounding - note; the legato `retune()` path is the only sanctioned mid-note pitch move. -- **The sample/capture identity and its decoded PCM.** Loading a different capture is a - new sound, not a parameter change. - -**Behavior — the live block.** Requirements, stated as constraints rather than as a -chosen design: - -- **Ownership outlives every `LoadedInstrument`.** One block per processor instance, - observed identically by `live_` and `draining_`. A drain-slot voice therefore keeps - tracking the knob — that is the *desired* behavior (it is the note the user is - hearing), and it should be stated so nobody later "fixes" it. -- **`PlayParams` stays a plain copyable value type.** `component_state_io`, `sample_map`, - and the editor all pass it by value; atomics cannot simply be pushed into it. -- **No lock on the audio path**, and **no torn read**: the editor writes on the UI - thread while `process()` reads on the audio thread, so observing half of one edit and - half of another mid-block is a real hazard the design must close, not a theoretical one. -- **No constructor growth, and no dependency grab-bag.** An `IServiceProvider`-shaped - parameter object threaded into `VoiceEngine`/`Voice` constructors is a smell and is - rejected. One candidate that satisfies both: a single `const LiveParams*` field on - `SampleData`, defaulting to `nullptr` so the bare core behaves exactly as today - (latched, byte-identical, existing tests untouched) and the shell sets it on the path - `SampleData` already travels. That is a [propose]-class mechanism note, not a mandate. - -**Behavior — the per-frame constraint (Daniel's standing non-negotiable).** Root -`CLAUDE.md`'s performance guardrails and structural heuristic 3 both bind here: - -- **No allocation and no virtual dispatch on the per-voice-per-sample path.** A per-frame - copy of a params struct is a violation; so is an added header→TU indirection. - `envelopes.h`'s evaluators and `Voice::advanceFrame` are header-inline by RT - constraint (no LTO configured) — concrete, no common base, no virtual `tick()`. -- **Observation happens at block boundaries, not per frame.** The voice already caches - its filter parameters in members (`filterCutoffNorm_`, `filterModAmount_`, - `filterKeyTrack_`, …); the minimal live implementation refreshes those cached members - at a block boundary instead of only at note-on, leaving the per-sample shape unchanged. - At 512 frames / 48 kHz that is ~93 Hz of control resolution — ample for a hand on a - knob, and the coarse floor for automation later. -- **Smoothing is required, and it is what makes the block-rate step inaudible.** A - block-rate jump in a base value is a step; `masterGain_`'s per-sample linear ramp - toward the target is the in-house answer. **The ramp must terminate exactly**, not - asymptotically: `tickFilterCutoff`'s two exact skips compare the value itself, so a - never-quite-arriving one-pole would pin the filter on the always-re-solve path forever. - Cost while a cutoff ramp is running is known and affordable — 15.5 ns/frame/voice - measured in `engine/filter/CLAUDE.md`, ~1.2% of one core at 16 voices — and it returns - to the skip path when the ramp completes. - -**Acceptance criteria.** - -- With a note held, sweeping filter cutoff, Q, morph, drive, mod amount, or key-track - audibly moves *that* note. Same for every stage time and stage level on all three - envelopes. -- A knob move on a live parameter performs **no** WAV re-decode and **no** snapshot - rebuild — verified against `reloadInstrument`, not by ear alone. -- A note sounding out of the **drain slot** responds to a live parameter move identically - to a `live_` voice. -- **No click, no step, no zipper** on any live parameter move, at any block size — - including a full-range cutoff sweep at maximum Q, and a sustain-level change on a note - held in Sustain. -- **The per-sample path costs nothing at rest.** With no live value changed, the render - is unchanged in call/inline shape from today; all live observation is at block - boundaries. No allocation, no lock, no virtual call is added on the audio thread. -- The audio thread never observes a partially-applied edit within one block. -- `PlayParams` is still a plain copyable value type, still passed by value by - `component_state_io`, `sample_map`, and the editor. -- **Velocity, the velocity-curve result, the note number, the pitch ratio, and the - decoded PCM are still latched at note-on.** Dragging a velocity-curve point does not - retune or re-gain a sounding note. -- **With no live block attached the core is byte-identical to today** — `sampler_core`'s - existing regression tests pass unchanged, including the bare-engine baselines. -- **Migration:** a project saved before this change reopens sounding identical. Persisted - values are unchanged; `ComponentState` gains no version bump unless the chosen - representation forces one, and if it does, the bump is additive and pre-existing blobs - lift with no audible change. -- The filter DSP's glide property is finally exercised by a test: a cutoff move across a - `prepare()` without a `reset()` produces no output discontinuity. - -**Open questions.** - -1. **Which edits stop triggering a reload? [Daniel]** Sample/capture selection genuinely - needs a reload; filter cutoff plainly does not. The line between them is an edit-model - decision, not an implementation one. Three candidate groupings, in widening order: - - **Grouping C — continuous controls only.** Only knob-valued continuous controls go - live (filter cutoff/Q/morph/drive/mod/key-track, envelope times and levels). Every - discrete toggle — play mode, pitch engine, filter law, filter enable, channel mode, - velocity curve — keeps reloading. Narrowest blast radius, smallest verification - surface, and it fully satisfies the ruling as stated. - - **Grouping B — capture-anchored edits reload.** C, plus: the three capture-anchored - overrides (root note, loop span, start frame) also reload, because they name - positions in the decoded PCM and `loadSelection` already treats them as one family. - Everything else goes live or drops to the tier-2 rebuild. - - **Grouping A — only the sound source reloads.** Reload if and only if the identity - of the decoded audio changes: capture selection and channel mode (a decode policy). - Everything else is live or a rebuild. Widest live surface, largest verification - surface, and the one that most nearly matches "these will be automatable - parameters." - No pick is made here. **Proposed fallback if Daniel would rather rule after seeing the - mechanism run:** build the mechanism and ship Grouping C, leaving B and A reachable as - later reassignments rather than rework — the tier a given edit sits in is a routing - decision at the editor's commit site, not a property of the block. That is a scope - proposal, not a decision taken. -2. **What does a stage-time change do to a voice already inside that stage? [Daniel]** - Sound-defining, and there is no single right answer. Bounded by one non-negotiable: - **it must not click.** A discontinuity in output level on a parameter move is a - defect, and a sustain level changed while a voice is held must glide, not step. The - candidates, with the click property of each named: - - **(i) Jump / recompute from absolute elapsed.** Keep `framesInStage_`, divide by the - new duration. This is what today's code would do if params were simply swapped — - `level_ = framesInStage_ / attackFrames`. Steps the level discontinuously. Fails the - non-negotiable unaided. - - **(ii) Clamp.** As (i), but a stage whose new duration is already exceeded completes - immediately. Still steps. - - **(iii) Re-derive rate from the current level.** Hold the level, recompute the - per-frame advance so the stage completes at the new duration measured *from now*. - Continuous by construction. Costs: the stage's total time becomes elapsed + - remaining rather than the dialed value, and it needs a rate representation. This is - what a hardware EG with a rate DAC does. - - **(iv) Hold normalized stage position.** Keep phase φ = elapsed / duration fixed - across the change, then advance at 1/newDuration. Continuous in level (φ unchanged → - level unchanged) and the remaining stage takes its share of the dialed duration. - - **(v) Track absolute elapsed against the new duration.** Identical to (i); listed so - it is visibly not a distinct third option. - - **(vi) Keep (i) and smooth the envelope output.** Accept the recompute and put a - short declick ramp on the envelope's output level — the codebase already owns a - bounded-blend declick primitive (`kDeclickDecay`, `seedDeclick`) built for exactly - this class of step. - Only **(iii)** and **(iv)** are continuous without added machinery, and both compose - with T2's curve exponent because both are expressed over normalized position, which - the exponent is a pure map of. **(vi)** is the option that makes the cheap rule - acceptable, at the cost of a second smoother. Sustain is a separate sub-case under - every candidate: it is a *level*, not a timed stage, so a live sustain edit is a direct - level step and needs the ramp regardless of which rule wins. -3. **Representation: atomics in the value type, or a second live representation? - [propose]** Two forks, both with real costs: - - **F1 — atomics in the value type.** Push atomics into the live-relevant fields of - `PlayParams` / `FilterParams` / `AdsrParams`. One model, no mapping to drift. - Blast radius is wide and probably disqualifying: atomics are non-copyable, and - `PlayParams` is passed by value by `component_state_io`, `sample_map`, and the - editor — the plain-copyable-value-type constraint above is a hard bound on how far - this fork can go. - - **F2 — a second live-parameter representation.** A parallel block the audio thread - reads, published by the UI thread. Keeps the value type plain; the cost is that the - model exists twice and the mapping between them can drift. Mitigation to require if - this fork wins: derive the live block from `PlayParams` through exactly **one** - explicit fold function, so there is a single writer and a single site to keep in - step. Three publication mechanisms sit under F2, and the choice among them is the - torn-read answer: a **seqlock** (odd/even generation counter, audio thread copies - the block once per block and retries on a torn read — the standard single-writer - lock-free pattern); **per-parameter atomics** (simplest, exactly what `masterGain_` - does today, but offers no coherence *across* parameters — a set like an envelope's - A/H/D/S/R can be observed mid-edit); or a **double-buffered block published by - atomic pointer swap** (coherent, no retry, needs a reclaim rule — and the processor - already owns that muscle in `live_`/`draining_`/`graveyard_`). -4. **Does the block need sample-accurate (sub-block) resolution now? [propose]** Daniel's - ruling names automation as the long-term destination, and VST3 delivers parameter - changes on the audio thread with sample offsets inside `ProcessData` — so per-block is - the coarse floor, not the ceiling. Building sub-block splitting now is speculative; - *foreclosing* it is the failure mode. The requirement this track must carry either - way: **the block's writer interface must not assume a UI thread**, so the audio - thread's own parameter-change queue can drive it later without a redesign. -5. **Does a live edit still persist immediately? [verify]** Today `commitAndReload` also - writes the edited set into `params_`, which is what `getState` serializes. A live path - must keep making that UI-thread write so a saved project carries the edit — but that - write is no longer the audio thread's source. Confirm by reading that nothing else - depends on the reload as its persistence trigger. +**Θ-W3-T1 has landed** — see `docs/COMPLETED.md` for the full narrative. Continuous +playback controls (filter cutoff/Q/morph/drive/mod amount/key-track; every stage time +and level on all three envelopes) are now delivered live to sounding voices via a +seqlock-published `LiveValues` block owned at processor-instance scope, above +`LoadedInstrument`, so a drain-slot voice keeps tracking the knob (the desired +behavior). `foldLive(const PlayParams&)` is the single derivation from the value type. +Both of Daniel's ruling questions are answered and implemented: **reload tier = +Grouping B** (root note, loop span, and start frame still reload; everything else +continuous goes live), and **mid-stage rule = candidate (iv), hold normalized stage +position** (φ = elapsed/duration held fixed across a duration change, then advancing +at 1/newDuration) — expressed over normalized position specifically so T2's +per-segment curve exponent, below, composes with it. **Trigger's %-length and fades +are NOT live** — they are baked into `SampleData` at build, so a Trigger-mode instance +gets zero live amp delivery until T2 folds the fade pair into the AHD. The five +non-live exclusions (`kKeyTrack`, `kFilterVel`, `kTrigLength`, `kTrigFadeIn`, +`kTrigFadeOut`) are documented in `src/core/instrument/ui/deck_groups.h`, now their +single home. #### Θ-W3-T2 — `staged-envelope-curves` -**Prerequisite: Θ-W3-T1 must land first** — see the wave preamble. Both tracks write +**Prerequisite: Θ-W3-T1 has landed** — see `docs/COMPLETED.md`. Both tracks write `envelopes.h`'s stage math, and this track's curve exponent must be authored as a map of -whatever normalized stage position T1's mid-stage rule establishes. +the normalized stage position T1's mid-stage rule holds fixed (φ = elapsed/duration, +candidate (iv)). **Goal.** Grow the envelope-overlay editor from an amp-only fixture into the shared graphical surface for every envelope, give every envelope shapeable segments, fix the @@ -944,174 +714,35 @@ schedule preference; the feature is not expressible. ### Ξ-W1 — Consolidated tracking, and the programmed-note model -**Depends on:** nothing in this phase. **Concurrency-safe with Phase Θ from Θ-W2 onward** — -see the note at the end of this phase. - -**Two tracks, in priority order.** Disjoint: T1 is entirely extension-side record-keeping; -T2 is a new pure module with no existing call site. - #### Ξ-W1-T1 — `tracking-consolidation` -**Goal.** Make the provenance/usage territory **one system, 100% robust** — recipe -provenance, live-instance usage, and recapture lineage as facets of the same -record-keeping, answering both safety-critical consumers from one place. - -**Consolidates item 17.** - -**Surface boundary — owns:** `core/model/provenance`, `core/model/owned_manifest`, -`core/wire/sample_usage`, `core/reclaim/prune_reconcile`, `shell/persist/` -(`usage_scan`, `prune_fs`, `ext_state_io`'s manifest/provenance keys), -`shell/actions/prune_action`. Instrument-side touch is limited to the usage-publish block -at the tail of `reloadInstrument` in `shell/instrument/processor_reload.cpp` — **this is -the one file shared with Phase Θ; if run concurrently, this track owns that block and -Θ-W1-T1 does not touch it.** - -**Behavior.** -- **One system, not three mechanisms.** Today the territory holds two separately-grown - mechanisms plus one new demand: - 1. the **capture-recipe fingerprint** — a thin reproducibility record of how a capture - was made, deliberately not a restorable chain, conservatively recording nothing when - the situation is ambiguous; - 2. the **instance-usage tracking** — each live instance declares the captures it holds, - so prune can never delete a capture a live instance is using, with a fail-safe stance - that unreadable usage state halts prune entirely; - 3. item 15's demand for **recapture lineage** — records tying usages of a capture to it - "by the provenance/recaptureing system," deciding replace-vs-add at bake time. - These stop being separate ad-hoc mechanisms. Every consumer — the prune's protection - decision, the resample's replace-vs-add decision, and any future lineage reader — is - answered from the one system. -- **What "100% robust" observably means.** Daniel stated the strength, not the mechanics. - Three observable implications follow from the stakes, and no further specifics are - invented: - - **No silent gaps.** Every capture the system itself creates is tracked from the moment - of its creation; a recapture carries its lineage **from birth, never backfilled**. - There is no window in which a system-created file exists untracked. - - **Fail-safe on unreadable or ambiguous state.** Tracking state that cannot be read - never yields the destructive answer: the prune deletes nothing (today's settled stance - — preserved and generalized, not relaxed), and the resample never takes the replace - branch on unreadable lineage. - - **Consumers cannot disagree.** The prune's protection answer and the resample's - tied-usage answer are different questions with different universes — item 15 settles - that the replace-vs-add universe is **narrower** than the prune-protection universe — - but both are computed from the same records, so they cannot drift apart. -- **Existing guarantees are the floor.** Consolidation must not weaken anything settled: - prune still deletes only the system's own orphans and never a referenced or live-held - capture; the fail-safe abort on unreadable usage state survives; the recipe - fingerprint's record-nothing-when-ambiguous conservatism survives **for the recipe - half**. The lineage half is the one place that conservatism is foreclosed — item 15's - replace-vs-add must be computable, so a recapture's lineage record is mandatory. -- **Why this is safety-critical, stated once:** this territory gates the system's only - file-deletion authority (prune) and its only capture-replacement act (resample) — the - two places where a tracking error loses a user's audio or sound. - -**Acceptance criteria.** -- **One consolidated tracking system answers both safety-critical consumers:** the prune's - protected set and the resample's replace-vs-add decision are each computed from it, per - their own settled rules; **no separate ad-hoc tracking mechanism remains in the - territory.** -- **No silent gaps:** a recapture created by item 15's bake is tracked from the instant it - exists — a bake followed immediately by a prune, or by a second bake, behaves correctly - with no window in which the recapture is untracked or its lineage absent. (Testable - ahead of item 15 by simulating a system-created file through the same path.) -- **Fail-safe throughout:** with tracking state made unreadable, the prune deletes nothing - (and reports what blocked it, per today's behavior) and the resample never takes the - replace branch; **no destructive act follows from ambiguity, anywhere in the territory.** -- **Every protection settled today holds undiminished after consolidation:** a capture - held by a live instance cannot be pruned; files the system did not create are - untouchable; unreadable usage state still halts the prune. -- **A pre-existing bank lifts into the consolidated system with no loss of protection and - no spurious lineage**, and never-recorded remains distinguishable from unreadable. -- **Item 15's other-references case is decidable:** for any capture, "does provenance-tied - usage exist" has a definite yes/no answer. -- The pure fold decisions stay pure and unit-tested without a DAW; the REAPER/filesystem - half stays in `shell/persist`. - -**Open questions.** None awaits a Daniel decision — the requirement and its strength are -his; the shape is review work. -- **The consolidated shape [propose].** What "one system" concretely is — one record - family, one authority, how the three facets relate — is design work proposed at review. -- **The lineage record [propose, jointly with item 15's naming-and-lineage question].** - What constitutes a "usage tie," when it is written, whether it is ever severed, and - whether iteration lineage is user-readable from the bank. -- **The recipe-fingerprint half of a recapture [propose].** A resample's recipe is the - instrument's own settings, not a track chain; whether the fingerprint records a - resample-shaped recipe — and what its conservatism means there — is proposed at review. - (The lineage half has no record-nothing option; the recipe half may keep one.) -- **Never-recorded vs. unreadable [propose].** Pre-existing captures predate lineage - records, and the two absences demand opposite treatment: never-recorded means no tied - usage exists (replace is legitimate); unreadable means fail-safe. How the consolidated - system distinguishes them — and how pre-existing banks lift in without weakening any - protection they enjoy today — is proposed at review. -- **NEW — does this absorb the deferred persisted-instance-identity fix? [propose].** - `docs/TODO.md` carries "Persist ReaSampler 9000 instance identity to let prune reclaim - de-referenced captures after reopen," deferred 2026-07-28 as low-risk (safe, but the - bank folder grows unbounded after a reopen). "Consolidated and made 100% robust" - arrived the next day. My reading is that they do not conflict — robustness is a *safety* - claim, the wart is a *completeness* one — but the consolidation is the natural moment to - revisit it, and the constraint it must handle is unchanged (a persisted identity is - inherited by a Ctrl+D in-place duplicate; a divergent clone must still be detected and - protected fail-safe without reintroducing the sibling-drop bug). **Fold it in or - explicitly restate the deferral at review; do not leave it ambiguous.** - ---- +**Landed** — see `docs/COMPLETED.md` for the full narrative. The provenance/usage +territory is now one system: a new `src/core/tracking/` directory holds `origin_ledger` +(the record family — `OriginRecord`/`OriginKind`, the insertion-ordered `OriginLedger`, +its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion` load +classification) and `tracking_authority` (the one decision surface: `pruneProtection` +and `tiedUsageExists`), retiring `core/model/owned_manifest`. Both prune's protected set +and the resample's replace-vs-add decision are computed from one borrowed +`TrackingState`, so the two safety-critical consumers cannot drift apart. +`sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the +decisions, not the codecs. The deferred persisted-instance-identity fix was **not** +folded in — the deferral is restated in `docs/TODO.md`, its one home. This track +landed `OriginRecord`'s birth-time `parentSampleId` chain as the lineage mechanism, but +**Ξ-W2-T1's "naming and lineage" open question (jointly held with this track) is +unaffected and still theirs to close** — display naming and user-readable iteration +lineage were not decided here. #### Ξ-W1-T2 — `note-program-model` -**Goal.** Land the programmed-capture-signal model as a pure, tested module: musical -divisions, tempo resolution, and offset anchoring — the arithmetic both the bake and the -popup will read. - -**Consolidates item 15 (the capture-signal model; the bake is Ξ-W2-T1 and the popup UI is -Ξ-W3-T1).** - -**Surface boundary — owns:** a new pure module (`core/instrument/map/note_program` or -similar, name [propose]) plus its tests target and the CMake row. New files only; no -existing file edited except `CMakeLists.txt`. Disjoint from T1 by construction. - -**Behavior.** -- **Note length is a musical division, not a free duration.** Chosen from divisions - spanning **1/64th through 64/1, with dotted and triplet multipliers** (Daniel's - examples: `1/8.`, `1/4t`, `1/16`, `4/1`). -- **Beats resolve against the project tempo under the cursor.** A beat-denominated value — - the note-length division always, the offsets when expressed in beats — resolves to time - against "the project tempo under the item cursor" (Daniel's phrase; read plainly: the - tempo in effect at the project's cursor position when the preview or bake runs). The - module takes the tempo as a parameter; **reading it from REAPER is the shell's job**, - not this module's. -- **Offsets anchor to note-on and note-off.** The start offset is relative to the - programmed note's **note-on**; the end offset is relative to its **note-off**. -- **Offsets are expressed in ms AND in beats** — the two denominations are two views of - one stored value, and the conversion is this module's. Note length is - musical-division-only. -- **Velocity is explicit** — a plain 1–127 value carried in the same record. Material - because the velocity transfer curves modulate amp (and, post-Θ, pitch and filter) at - that velocity. -- **The record is one struct**, round-trippable, that the bake renders from and the popup - edits. One source of truth: **preview and bake cannot diverge** because they read the - same record through the same resolver. - -**Acceptance criteria.** -- The division set spans 1/64 through 64/1 inclusive, with dotted and triplet variants, - and each resolves to the correct duration at a given tempo — asserted at the extremes - and at Daniel's four named examples. -- The same division yields a correspondingly different duration when the supplied tempo - differs (tests assert the proportionality, not a hardcoded rate). -- Offsets round-trip losslessly between ms and beats at a given tempo. -- Anchoring is explicit in the type: a start offset is note-on-relative and an end offset - is note-off-relative, and the resolved window is computed from both plus the note length. -- **No hardcoded sample rate and no hardcoded tempo** anywhere in the module (the standing - rate-free-seconds ruling). -- Builds and tests without REAPER, VST3, or a DAW. - -**Open questions.** -- **Are negative offsets meaningful? [propose]** Unchanged from the source doc. A negative - start offset (capture beginning before note-on) is plausible; a negative end offset - (truncating before note-off) is also plausible. Propose the answer with the type. -- **The denomination seam [propose].** The first answer round expressed the offsets "in ms - AND in beats" while note length is musical-division-only. The plain reading is that the - ms/beats duality applies **to the offsets only**. If an ms display or entry for note - length seems wanted at implementation, propose it at review rather than assuming either - way. +**Landed** — see `docs/COMPLETED.md` for the full narrative. The programmed-capture- +signal model is a new pure module directory, `src/core/instrument/note/` — a fourth +peer of `engine/`/`map/`/`ui/` under `core/instrument/` — holding `musical_division`, +`tempo`, and `note_program` (`Velocity`, the denominated `OffsetAmount`, the anchored +`StartOffset`/`EndOffset`, `NoteProgram`, `resolveNote`). **Both open questions below +are answered, for Ξ-W3-T1:** negative offsets are legal in both directions (sign +uniform, positive is later in time; only an inverted window is refused, reported via +`ResolvedNote::windowCollapsed`), and the denomination seam is confirmed — note length +stays musical-division-only, and an offset stores the denomination it was entered in. --- @@ -1360,24 +991,6 @@ must be closed. --- -### Running Θ and Ξ concurrently - -**Ξ-W1 is concurrency-safe with Phase Θ from Θ-W2 onward.** Ξ-W1-T1 is extension-side -record-keeping (`core/model`, `core/wire`, `core/reclaim`, `shell/persist`, -`shell/actions`); Ξ-W1-T2 is a new pure module. Neither is on Θ's critical path. - -**Two conditions.** -1. **Not during Θ-W1.** Θ-W1-T1 re-seams the whole instrument, including - `processor_reload.cpp`, which is the one file Ξ-W1-T1 also touches (the usage-publish - block). Wait for Θ-W1 to land. -2. **Ξ-W1-T1 owns the usage-publish block** for the duration; no Θ track edits it. - -**Recommendation:** run Ξ-W1 concurrently if a specialist is spare — it ships prune -robustness early and removes the largest dependency from Ξ-W2's critical path. Otherwise -sequence it after Θ. This is a scheduling call, not a plan decision. - ---- - ## Traceability — all seventeen items The check that nothing was dropped. Every row points at a track that exists above. diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 9665d47..a21413b 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -1,8 +1,8 @@ -# src/core/instrument — pure VST3-instrument core (engine / map / ui) +# src/core/instrument — pure VST3-instrument core (engine / map / note / ui) ## Scope -The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three +The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in four subdirectories: - **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting, @@ -11,6 +11,9 @@ subdirectories: `ComponentState` codec, and the small pure helpers the engine/shell share (bank-generation sync, bridge-read marshalling, note-name parsing, Trigger frame↔fraction conversion). +- **`note/`** — the programmed capture-signal model: musical-division note length, tempo + resolution, and anchored start/end offsets — the one record and resolver a + capture-signal popup and the offline bake read from, so they cannot diverge. - **`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