docs: record Ξ-W2-T1, the resample bake chain, and file its two deferrals

This commit is contained in:
2026-08-01 18:11:46 -04:00
parent 7ecb3aa470
commit 9f17df1420
3 changed files with 155 additions and 174 deletions
+85
View File
@@ -669,3 +669,88 @@ both corrected the initial reading of each.
All visual outcomes remain **pending Daniel's by-eye sign-off on `dev`** — nothing was
verified in a live REAPER window; all measurement was against an offscreen bitmap in a
standalone harness. Not recorded as accepted.
### Ξ-W2-T1 — resample-bake-chain
The one-click in-sampler resample: dial → bake → dial-again, run without leaving the
sampler. A single click renders the dialed sound through the instrument's own voice
path, banks the result, re-points the instance at it, and hands the parameter set back
neutral — with the recapture's superseded predecessor never deleted, only retired to
prune's reclaim pool.
**The architecture decision was this track's first deliverable, and Daniel ratified
both halves of it.** Decision 1 (how the click crosses to the extension) is **(1b)**:
the editor invokes the extension's bake action directly over the VST-host bridge —
`NamedCommandLookup` on `"_" + channelCommandId(...)`, then `Main_OnCommandEx` — so
there is no request poller, no nonce, and no cross-process handshake. This dissolves
the S13 DEGRADED verdict rather than re-litigating it. Decision 2 (what renders the
audio) is **(2c)**: the instrument renders in-process and the extension banks the
file — taken **over the plan's leaning toward (2a)** on an engine-version-skew
argument: under (2a) the extension's own copy of the voice engine would render audio
the user heard through the VST3's separately-installed copy, and the format ladders
do not catch a behavioral divergence between the two. (2c) also leaves the
extension's link graph untouched, preserving `component_state_io`'s split-out purpose
of keeping engine object code out of the extension.
**What shipped:**
- New pure modules: `src/core/instrument/bake/` — a fifth peer of `engine/`/`map/`/
`note/`/`ui/` under `core/instrument/`, holding `bake_plan`, `bake_render`, and
`bake_reset` — plus `core/model/resample_name` and `core/wire/bake_wire`.
- New shells: `shell/instrument/instrument_bake` (the instrument's half: render,
stage the WAV outside the bank, publish one `rsbake_<guid>` request, invoke the
extension's action synchronously, read the outcome back, adopt + reset) and
`shell/capture/bake_land` (the extension's half: scans every open project tab for
pending requests, lands the ones belonging to the loaded project, refuses the rest).
- One new `ActionTableRow`, `RESAMPLE_BAKE`, registered through `main.cpp`'s existing
data-driven table.
- The instrument's guarded ext-state write surface grew from one prefix (`rsusage_`)
to two (`+ rsbake_`); the read-only-**bank** invariant holds because a bake
request key is not bank state, and the structural prefix guard still refuses
`banks`/`view`/`tail`/`assign`.
**Reset-scope classifications made at review, against Daniel's ratified rule** — these
parameters were absent from both ratified lists, so the classification itself is this
track's durable output:
- **Play mode → RESET, to Trigger.** The bake's product is a finished one-shot
carrying its own attack, span, and release; Gate would re-gate it and re-truncate
the printed tail on every iteration, breaking "iteration composes indefinitely." The
user-visible consequence: after a bake the instance is in Trigger, and a sustained
instrument needs Gate re-dialed by hand.
- **Start point → RESET.**
- **Channel mode and preview velocity → SURVIVE.**
- `resetAfterBake` defaults everything and copies back only the survivors, so a
parameter added later resets by default.
**Two behaviors worth recording because they are user-visible:**
- A bake fired from an instance in a **background project tab refuses** with
`BakeStatus::WrongProject` rather than risking a write into the wrong project's
bank — landing requires three-way agreement between the request's tab, the
session's loaded project, and the focused tab.
- A crash-stranded bake request is **cleared, not landed**, past a 30-second
staleness window (`kMaxRequestAgeSeconds`).
- Extension presence: the resample affordance reads **cleanly unavailable, not
silently lossy**, when the extension is not loaded — `bakeAvailable` gates the
editor's paint state and `runBake` refuses up front with "resample needs the
ReaSampler extension loaded" if asked anyway.
**Not demonstrated.** Nothing was verified in a live REAPER session. The
audible-and-faithful, iteration-composes, save/reload, and arrange-untouched
acceptance criteria are structural in the code and untested in a DAW. Three facts
remain DAW-unverifiable and are handled defensively rather than asserted:
`NamedCommandLookup`'s return on an absent command, `Main_OnCommandEx`'s `flag`
semantics, and whether a `WM_TIMER`-issued invoke is honoured. Daniel's manual
verification is still owed.
**Naming and lineage — proposed, not ratified, and still open jointly with
Ξ-W1-T1's lineage-record question.** This track's proposal: `Kick``Kick r2`
`Kick r3`, incrementing rather than stacking; a replace keeps the source's name;
machine-readable lineage rides `OriginRecord::parentSampleId`, written at birth
(landed by Ξ-W1-T1). The proposal is implemented (`core/model/resample_name`) but not
itself a ratified decision.
**Deferred, not done — logged to `docs/TODO.md`:** `Sample::sourceMode` has no value
meaning "produced by the instrument" (appending one is a forward-incompatible
bank-format change under the current deserializer, which fails the whole bank blob on
an out-of-range value — it wants its own decision); and `instrument_bake` copies the
interleaved render buffer into a `std::vector<double>` for the WAV build, roughly
doubling peak memory for a large bake.
+16 -174
View File
@@ -435,180 +435,22 @@ head governs every step after it.
#### Ξ-W2-T1 — `resample-bake-chain`
**Goal.** One click bakes the dialed sound into a bank capture, re-points the instance at
it, and hands the instrument back at neutral — the dial → bake → dial-again loop, run
without leaving the sampler.
**Consolidates item 15 (the chain; the popup UI is Ξ-W3-T1).**
**Surface boundary — owns:** whichever seam the architecture decision selects (see below),
plus `shell/capture/capture_orchestrator` (a resample entry point alongside
`captureAndIndexOne`), `core/model/bank_model` + `owned_manifest` at the add boundary,
`shell/actions` (a new action, if the decision needs one), and the instrument-side bake
trigger. **Does not own** the capture-signal popup's UI — that is Ξ-W3-T1.
##### The architecture decision — this track's first deliverable, before any chain work
Item 15 requires a click **inside the VST3 editor** to cause a **bank write**, and the
instrument is a read-only bank consumer by invariant. The one prior attempt at this
crossing (S13) closed **DEGRADED** and was deferred. Resolve this before building the
chain. Two orthogonal choices:
**Decision 1 — how the click crosses to the extension.**
- **(1a) Ext-state request key + extension-side timer poller**, with a claim/clear nonce —
the shape S13 spiked and deferred. It needs a new instrument WRITE seam into ext-state,
though the `rsusage_`-prefixed guarded write already establishes the precedent that such
a seam can exist without weakening the read-only-*bank* invariant. Known cost: the
cross-process handshake race the S17 spec rejected.
- **(1b) Direct action invocation over the VST-host bridge — the lead candidate.** A
REAPER-hosted VST3 already resolves REAPER API functions **by name** over the host
callback (that is how the instrument reads `GetProjExtState`), and can fetch its own
host context — the track/take/project it was instantiated in. If `NamedCommandLookup`
and `Main_OnCommand` resolve the same way, the editor can invoke the extension's bake
action **synchronously**, with no request key, no poller, and no nonce — which dissolves
the S13 problem for this case rather than re-litigating it. **[verify]** against
`vendor/reaper-sdk/sdk/` (`reaper_plugin.h`, `video_processor.h`,
`reaper_plugin_functions.h`) that both resolve over the callback, and that calling them
from the editor's UI thread is safe. This is a verification task, not an assertion — do
not build on it until confirmed.
- **(1c) No crossing — a bindable extension-side action only.** Fallback. It abandons
Daniel's "from directly inside the ReaSampler 9000" framing, so it is the answer only if
(1a) and (1b) both fail. If it is taken, say so explicitly rather than quietly shipping
a panel button.
**Decision 2 — what actually renders the audio.**
- **(2a) Headless voice-engine render, extension-side.** The extension reads the instance's
component state, reconstructs the engine from the shared pure core, renders the
programmed note, and banks the result through the normal capture add-path. Exact and
fully deterministic. **Trade-off:** the extension currently links `component_state_io`
but deliberately **not** `sampler_core`/`pitch_shift``component_state_io` was split out
precisely to avoid pulling the voice engine into the extension. This reverses that.
- **(2b) REAPER offline render of the hosting track**, with the programmed note. Reuses
`OfflineRenderBackend` and with it the whole precision-invariant apparatus — exact
bounds, bit-identical repeats, relative paths, tail control — for free. **Trade-offs:**
it needs a *temporary* arrange mutation to program the note (the realtime backend's
temp-track-created-and-removed-cleanly is the precedent), and the existing scope model
has no "this one FX only" scope, so post-instrument track FX would be baked in unless a
new bypass scope is added. The arrange must be byte-identical afterward.
- **(2c) The instrument renders, the extension banks it.** The instrument produces the
audio (it already owns the engine) into a temp file or shared buffer; the extension
moves it into the bank and indexes it. Splits the work along the existing ownership
line — but adds a handoff artifact and its cleanup.
**Recommended sequence for the decision:** verify (1b) first — if it holds, the crossing
is nearly free. Then pick between (2a) and (2b) on the strength of the
"only-the-instrument, not the track chain" requirement: Daniel's ask says "send a trigger
or gate **through the sampler** offline," which favours (2a) or (2c) unless (2b) gains a
single-FX bypass scope. Record the verdict in this track's review, and if the verdict is
DEGRADED, escalate rather than improvise — the fallback is (1c) + (2a).
**Behavior — the chain.**
- **One gesture, whole chain.** A single click performs: offline pass → capture → bank
update (replace or add-distinct) → instance re-point → parameter reset. **One action
from the user's side, not a wizard.**
- **Offline pass through the instrument's own processing.** The audio is produced by
sending a trigger or gate through the sampler **offline** — the instrument's own voice
path, with filtering, pitching, and amp exactly as dialed. The recapture is of that
processed output, not of the raw source.
- **The recapture is a bank capture like any other** — project-relative, indexed,
browsable from any surface that browses the bank, recorded in the owned-file manifest,
and governed by the same safety rules as every file the system creates.
- **The note is the capture's root.** The rendered note is the capture's root note — which
is exactly why **the root-note parameter is not reset by resampling**: capturing at root
is what makes root survivable, and resetting it would detune every subsequent iteration.
- **Gate's hold and tail come from the programmed window.** The programmed note length is
the Gate hold bound — the gate holds for the note length, then releases; a Gate loop
cycles within the held span and the render still terminates. The **end offset** is the
home of the tail policy: captured time past the note's end is where the release rings.
- **Replace, or add distinct.** When nothing else references the source capture, the
recapture **replaces** it as the bank entry; when other references exist, the original
entry stays and the recapture is **added as a new distinct capture**. "Other reference"
means **any usage of the source capture tied to it by the provenance/recapture system**
— Ξ-W1-T1's records. Read plainly: the reference universe is the resample system's own
lineage records, **not** the prune-protection universe. Bank multi-membership, items
placed in the arrange, and a plain hold by another instance outside any recapture
lineage do **not** force add-distinct, and need not for safety — the superseded file
survives until prune, and prune's protection universe is unchanged and broader.
- **Replacement never destroys audio bytes.** "Replace" means the bank entry now denotes
the recapture; the superseded file is not deleted. Prune remains the system's only
file-deletion authority — resample writes a new file and retires the old one to
reclaimable-by-prune status; it never overwrites or deletes it. Until a prune reclaims
it, the pre-bake audio survives on disk — **the iterate loop's built-in recovery floor.**
- **The instance re-points** at the new/corrected bank capture and plays the baked sound.
- **Parameters reinitialize to default** — destructive to the dialed settings **by
design**: the processing now lives in the recaptured audio, and neutral controls are the
starting point for the next iteration.
- **Reset scope: only what the bake baked in.** Ratified by Daniel, not derived:
- **Reset** (their effect is in the audio): the envelope contours — staged and spline
alike — the filter parameters, master gain, the pitch envelope/engine settings, the
velocity transfer curves (their effect at the programmed velocity is in the audio), and
the loop points (they shaped the render, and old positions are meaningless against new
audio).
- **Survive** (mapping facts, not present in the audio): the **root note**, whatever
remains of key mapping (key-tracking; any key-range concept Θ-W1-T1 settled), and the
VOICE group (polyphony behavior leaves no trace in a single rendered note).
- **No timeline item, ever.** Resampling is a capture act: it writes a file to the bank and
updates the index; nothing is placed in the arrange view. Any framing that auto-inserts
the recapture is invalid — and if architecture (2b) is chosen, its temporary programming
item must leave the arrange byte-identical.
- **Undo/recovery: a plus, not a requirement.** Welcome if it falls out cheaply; the
feature ships without it. The guaranteed recovery path is the superseded file surviving
on disk until a prune reclaims it.
**Acceptance criteria.**
- On a dialed-in instrument, one click yields all of: a recapture in the bank, the instance
holding that recapture, and the baked-in audio parameters at their defaults — with the
root note and the other surviving mapping parameters untouched.
- **The bake is audible and faithful:** after the click, playing the programmed capture
note (root, at the programmed length, offsets, and velocity, in the active mode) through
the now-neutral controls sounds as the dialed instrument sounded just before the click —
the processing has moved from the controls into the audio.
- **A Gate-mode bake terminates on its own:** the gate holds for the programmed note
length, then releases — even with loop-sustain active, the render ends. No indefinite
capture.
- **After the bake the root note is unchanged** — iteration never detunes; the next bake
plays the same root.
- **Sole-reference case:** the bank afterwards shows the recapture where the source
capture's entry was; no other bank entry is disturbed. **Other-references case**
(provenance-tied usage of the original exists, computed by Ξ-W1-T1): the original entry
is untouched, a distinct new entry appears, and **every other holder of the original
sounds exactly as before.**
- **The click deletes no file:** the superseded audio file still exists on disk afterwards,
and only a later prune — under the settled orphan rules, only when nothing references it
— can reclaim it.
- **The arrange timeline is untouched:** no item appears anywhere, on any track, and if a
temporary programming item was used it is gone and the arrange is byte-identical.
- **Iteration composes:** dial → click → dial → click bakes the second pass onto the
first's result, repeatable indefinitely.
- **Save/reload:** an instance holding a recapture reloads and plays it exactly like any
other loaded capture.
- The bake adds nothing to `process()` — the offline pass runs off the audio thread.
**Open questions.**
- **The architecture decision [propose, with a verification step].** See above. The (1b)
bridge verification is **[verify]**; the choice among the candidates is **[propose]**.
- **Extension presence [propose].** The instrument plays self-contained with the extension
absent, but the bank is the extension's surface and resampling mutates the bank. The
natural answer is that resample **requires the extension present and is cleanly
unavailable — not silently lossy — without it**; propose the exact behavior at review.
- **Reset-scope edge cases [verify].** The rule is settled and the per-parameter
classification is ratified. Only a genuinely new parameter — one arriving with a Θ track
and absent from the ratified lists — is classified **against the rule** and surfaced at
review. Not a new Daniel call. (Θ adds: curve exponents → reset; spline contours →
reset, already named; the filter's velocity/key-tracking mod → reset with the filter;
loop crossfade → reset with the loop points; the Staged/Spline mode flag → classify.)
**Phase Γ's additions are NOT this track's** — this track ran ahead of Γ, so the values Γ
introduces (rate, pitch offset, the limiter flag, the loop enable) are amended in by
**Γ-W3-T2 `bake-reset-amendment`** afterwards, against what this track actually shipped.
Γ-W4-T1 then adds the host-notification obligation over the completed list. Nothing here
needs to anticipate either.
- **Naming and lineage [propose, jointly with Ξ-W1-T1's lineage-record question].** When
add-distinct fires, the new capture needs a display name (derived from the original?),
and the bank some way to read iteration lineage across repeated bakes. One proposal,
both tracks.
- **Provenance of the recapture — homed in item 17**, i.e. Ξ-W1-T1. A resample's recipe is
the instrument's own settings, not a track's chain. Nothing to decide here beyond
consuming that answer.
**Landed** — see `docs/COMPLETED.md` for the full narrative. The architecture decision
(this track's first deliverable) is ratified: **Decision 1 = (1b)**, the editor invokes
the extension's bake action directly over the VST-host bridge
(`NamedCommandLookup`/`Main_OnCommandEx`), no poller/nonce, dissolving the S13 DEGRADED
verdict; **Decision 2 = (2c)**, the instrument renders in-process and the extension
banks the file, taken over the plan's leaning toward (2a) on an engine-version-skew
argument. **Extension presence** resolved to cleanly-unavailable, not silently lossy —
the affordance refuses up front when the extension is not loaded. **Reset-scope edge
cases** classified against the ratified rule: play mode → RESET to Trigger, start point
→ RESET, channel mode and preview velocity → SURVIVE. **Phase Γ's additions remain
NOT this track's** — Γ-W3-T2 `bake-reset-amendment` still amends the reset list for
Γ's own new values, unaffected by this landing. **Naming and lineage** is proposed
(`Kick``Kick r2``Kick r3`) but not itself ratified — still open jointly with
Ξ-W1-T1's lineage-record question, which Ξ-W1-T1 answered on its own side
(`OriginRecord::parentSampleId`). Nothing was verified in a live REAPER session;
Daniel's manual verification is still owed.
---
+54
View File
@@ -270,6 +270,60 @@ decode; sounding voices are still destroyed across it, with no ghost-resurrectio
a pre-v10 blob still lifts; and `getLatencySamples()` still derives from persisted state rather
than from a transient the deactivate cleared.
## `Sample::sourceMode` has no value meaning "produced by the instrument"
**Context (what shipped — Ξ-W2-T1, resample-bake-chain).** A resample bake's landed
`Sample` entry (`bake_land.cpp`) never sets `sourceMode`; it is left at the struct
default (`SourceMode::MasterMix`) rather than recording that the entry's audio came
from the instrument's own offline render, not from a capture backend.
**The wart.** A baked capture is indistinguishable, by `sourceMode`, from a master-mix
render — the bank has no way to tell "this file was produced by ReaSampler 9000" from
"this file was rendered off the master bus."
**Intended fix.** Add a `SourceMode` value for instrument-produced audio and set it at
the one landing site.
**The constraint the fix MUST handle.** `bank_model.cpp`'s deserializer rejects any
`sourceMode` value outside `MasterMix(0)..Realtime(5)` by failing the whole bank
blob's parse (`parseSample` returns `false`), not just that one field — so appending a
new enumerator is a forward-incompatible bank-format change: an older extension build
reading a newer project's bank would refuse to load it entirely. This needs its own
decision (a version-gated field, or accepting the compatibility cost) rather than a
one-line enum append.
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review rather than folded in.
**Done looks like.** A baked capture's `sourceMode` reads as instrument-produced, and
the compatibility question (how an older build reads a bank containing the new value)
is answered rather than left to fail closed by accident.
## `instrument_bake` doubles peak memory on the WAV build
**Context (what shipped — Ξ-W2-T1, resample-bake-chain).** `runBake` (`instrument_bake.cpp`)
copies the render's interleaved `float` buffer (`BakeAudio::interleaved`, `AudioSample
= float`) into a `std::vector<double>` before handing it to `buildFloat32Wav`, which
takes doubles and narrows back to float for the bank's 32-bit-float WAV contract.
**The wart.** The copy roughly doubles peak memory for the bake — an 8-byte double
holding a value that started and ends as a 4-byte float — for the duration of the WAV
build on a large bake.
**Intended fix.** Either give `buildFloat32Wav` (or a sibling entry point) a
`float`-input overload so the bake path narrows nothing it doesn't already own in
`float`, or narrow lazily during the WAV build instead of pre-copying the whole
buffer.
**The constraint the fix MUST handle.** `buildFloat32Wav`'s `double` parameter is
shared with every other caller in `core/capture/wav_codec`; a fix must not change
those callers' contract or add a second WAV-building code path to maintain.
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review; correctness is
unaffected, only peak memory on a large bake.
**Done looks like.** A bake's peak memory no longer includes a full double-precision
copy of the rendered buffer, with `buildFloat32Wav`'s other callers unchanged.
## The deck layout rework — SPECCED, and the original shape SUPERSEDED
**Status (2026-08-01): no longer a deferral. The design notes Daniel owed this entry have