Ξ-W2-T1 remediation: print master gain into the bake, derive the window from the dialed sound, reset play mode to Trigger

This commit is contained in:
2026-08-01 17:05:28 -04:00
parent 60308a3655
commit 39c2d1cdb4
31 changed files with 679 additions and 201 deletions
+27 -12
View File
@@ -18,26 +18,35 @@ decision about what the render made obsolete.
can neither be observed nor disturbed by a bake, and a repeated bake of one dialed sound
is byte-identical because nothing outside the passed value can vary between runs.
- **The window bounds the render; the envelope does not.** Termination is structural — the
loop runs to `BakePlan::totalFrames` and stops. That is why a Gate bake with a sustain
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
even a pathological envelope cannot run past the window.
- **The block size is fixed here, not taken from the host.** A block boundary is where the
engine re-observes state, so pinning it is part of what makes two bakes on two hosts
produce the same bytes.
- **A degenerate window is refused, not rendered.** `planBake` returns nullopt for a
collapsed window, a non-positive rate, or a window that rounds to no frames.
- **The whole signal chain is printed, master gain included.** `renderBake` scales its
output by the dialed post-mixer gain, because `resetAfterBake` hands that control back at
unity. A render that summed voices alone would return every iteration shifted by 1/gain,
and a gain dialed to silence would come back at full level.
- **A degenerate or unholdable window is refused, not rendered.** `planBake` returns nullopt
for a collapsed window, a non-positive rate, a window that rounds to no frames, and one
past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
seconds→frames narrowing is undefined long before the allocation would fail.
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
starts from a default-constructed parameter set and copies back only the mapping facts.
A parameter added later therefore resets by default — the safe direction, since
under-resetting applies the same processing twice while over-resetting costs a re-dial.
A new mapping fact must be added to the copy list explicitly.
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
classification this track made against the ratified rule rather than reading off it. The
bake's product is a finished one-shot, and Trigger is the mode that plays a finished
one-shot verbatim; Gate would re-gate the printed release tail and each iteration would
truncate the previous one's. "Neutral" here means "adds no processing", not "the struct's
own default". `bake_reset.cpp` carries the argument at the assignment.
## Modules
- `bake_plan``defaultBakeProgram` (the program a bake uses until the capture-signal
popup ships; its release tail exists so the bake is not truncated at note-off),
`BakePlan` (the frame window plus its two event frames), and `planBake`, the one
`ResolvedNote` + rate -> frames resolution.
- `bake_plan``defaultBakeProgram` (the program a bake uses until the capture-signal popup
ships; its end offset is DERIVED from the dialed sound, never constant), `BakePlan` (the
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`planBake`, the one `ResolvedNote` + rate -> frames resolution.
- `bake_render``BakeAudio` and `renderBake`: the programmed note through the sample's
own voice path, summed into an interleaved buffer at the source's own channel count.
- `bake_reset``BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
@@ -45,7 +54,13 @@ decision about what the render made obsolete.
## Gotchas
- Frame 0 of the render is the start of the CAPTURED FILE, not note-on. A capture that
opens before the note has `noteOnFrame > 0` and silence ahead of it.
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
offset from each other whenever the note and the capture window do not start together.
`bake_plan.h` says which field is in which; do not read them as one clock.
- **`defaultBakeProgram`'s Trigger window bounds the Varispeed read stretch, it does not
model it.** A downward pitch offset makes the read head take longer to cross the play
span, so the window is scaled by the deepest downward offset the voice can reach — an
upper bound, so a shallower excursion leaves trailing silence in the file. The
capture-signal popup is where a user sets the window exactly.
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
+5 -3
View File
@@ -1,6 +1,8 @@
# Links only note_program: a plan is the programmed note resolved against a rate, and
# nothing about the engine or the bank is needed to compute one.
reasampler_pure_library(bake_plan SOURCES bake_plan.cpp LINK PUBLIC note_program)
# The default program's window is derived from the DIALED sound, so the plan reads the
# engine's value layer (sampler_core) and the one Trigger span formula (trigger_seam).
reasampler_pure_library(bake_plan
SOURCES bake_plan.cpp
LINK PUBLIC note_program sampler_core trigger_seam)
reasampler_test(bake_plan LINK bake_plan)
reasampler_pure_library(bake_render
+71 -17
View File
@@ -5,6 +5,8 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
namespace reasampler::instrument::bake {
using note::NoteProgram;
@@ -12,17 +14,62 @@ using note::ResolvedNote;
namespace {
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here
// uses, so the window and its two event frames cannot round against each other.
std::int64_t toFrames(double seconds, int rate) {
return static_cast<std::int64_t>(std::llround(seconds * static_cast<double>(rate)));
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here uses,
// so the window and its event frames cannot round against each other. Reports failure
// rather than clamping: the double->int64 narrowing below is undefined once the product
// leaves int64's range, which a legal offset magnitude reaches long before that.
bool toFrames(double seconds, int rate, std::int64_t& out) {
const double frames = seconds * static_cast<double>(rate);
const auto ceiling = static_cast<double>(kMaxBakeFrames);
if (!(frames >= -ceiling && frames <= ceiling)) return false; // also catches NaN
out = static_cast<std::int64_t>(std::llround(frames));
return true;
}
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
// stretches how long Trigger's source span takes to play. Preserve decouples the two, and a
// Gate release is ticked per output frame, so neither is affected.
double downwardSemitones(const PlayParams& play, int velocity) {
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
return down;
}
} // namespace
NoteProgram defaultBakeProgram() {
NoteProgram p;
p.end = note::EndOffset(note::offsetFromMs(kDefaultReleaseTailMs));
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo) {
NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on
if (renderSampleRate <= 0) return p;
const double rate = static_cast<double>(renderSampleRate);
double endOffsetSeconds = 0.0;
if (dialed.play.playMode == PlayMode::Trigger) {
// Trigger ignores note-off entirely: the sound ends when the read head reaches the
// play span's end, which has nothing to do with the note's length — so the end
// offset is whatever is left after the note, positive or negative.
const std::int64_t span = map::triggerPlayLength(
dialed.play.trigger.lengthFraction,
static_cast<std::int64_t>(dialed.frames.size()), dialed.startFrame);
const double stretch = std::pow(
2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
endOffsetSeconds = static_cast<double>(span) / rate * stretch -
tempo.beatsToSeconds(note::divisionBeats(p.length));
} else {
// Gate: the release is the one stage that runs after note-off, so it is exactly
// what the window has to hold past it.
endOffsetSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
}
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
return p;
}
@@ -31,20 +78,27 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
if (resolved.windowCollapsed) return std::nullopt;
if (sampleRate <= 0) return std::nullopt;
// The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
// start offset is legal and means the capture opens after the note — so the head is
// rendered and discarded, never folded away by sliding note-on later inside the window.
const double renderStartSeconds = (std::min)(resolved.captureStartSeconds, 0.0);
BakePlan plan;
plan.sampleRate = sampleRate;
plan.totalFrames = toFrames(resolved.captureLengthSeconds(), sampleRate);
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
return std::nullopt;
if (plan.totalFrames <= 0) return std::nullopt;
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
plan.leadInFrames))
return std::nullopt;
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt;
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
plan.noteOffFrame))
return std::nullopt;
// Each field cleared the ceiling alone; the render holds their sum.
if (plan.renderFrames() > kMaxBakeFrames) return std::nullopt;
// Note-on sits at -captureStart into the window: a negative start offset (the capture
// opens early) pushes it later, a positive one has already been clamped away by
// resolveNote's own window.
plan.noteOnFrame = std::clamp(toFrames(-resolved.captureStartSeconds, sampleRate),
std::int64_t{0}, plan.totalFrames);
plan.noteOffFrame =
std::clamp(toFrames(resolved.noteOffSeconds - resolved.captureStartSeconds,
sampleRate),
plan.noteOnFrame, plan.totalFrames);
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
plan.note = std::clamp(rootNote, 0, 127);
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
return plan;
+31 -18
View File
@@ -1,5 +1,5 @@
// bake_plan — the programmed note resolved against a concrete sample rate: the frame
// window the offline pass renders, and the two event frames inside it.
// bake_plan — the programmed note resolved against a concrete sample rate: the frames the
// offline pass renders, the slice of them the capture keeps, and the two event frames.
//
// Separate from bake_render because the plan is what a preview and a bake must agree on;
// the render is only one consumer of it.
@@ -9,36 +9,49 @@
#include <cstdint>
#include <optional>
#include "core/instrument/engine/play_params.h" // SampleData (the dialed sound)
#include "core/instrument/note/note_program.h"
namespace reasampler::instrument::bake {
// Until the capture-signal popup ships, the bake needs a program to render. A zero end
// offset ends the capture exactly at note-off, which truncates every release — so the
// default opens the window past the gate by this much.
inline constexpr double kDefaultReleaseTailMs = 500.0;
// The render's frame ceiling, refused like any other degenerate window. A legal offset
// magnitude reaches ~11.6 days, and renderBake allocates two channel buffers plus an
// interleaved one from the window — an unbounded one is a bad_alloc inside a UI tick, not a
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
// The program a bake uses when nothing has been entered: one quarter note at the default
// velocity, opening at note-on and closing kDefaultReleaseTailMs after the release starts.
note::NoteProgram defaultBakeProgram();
// The program a bake uses until the capture-signal popup ships: one quarter note at the
// default velocity, opening at note-on, with the END offset derived from `dialed` — Gate's
// release, or Trigger's play span, at `renderSampleRate` (the rate the bake will render at,
// which is what the engine's frame counts are actually consumed against). Derived rather
// than constant because the release knob alone spans two seconds, so any fixed tail cuts a
// long decay mid-flight.
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo);
// The render window in frames. Frame 0 is the start of the captured file, NOT note-on:
// a negative start offset opens the capture before the note, and noteOnFrame is where the
// note actually lands inside it.
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
// length, everything else counts RENDER frames from whichever comes first, note-on or the
// capture opening. A positive start offset (legal — it trims the attack) puts note-on at
// render frame 0 and the file's frame 0 `leadInFrames` later; a negative one does the
// reverse, and the file opens on silence before the note. Either event frame may sit past
// the render, which then closes before the note ever fires — a legal empty capture.
struct BakePlan {
std::int64_t totalFrames = 0;
std::int64_t noteOnFrame = 0; // in [0, totalFrames]
std::int64_t noteOffFrame = 0; // in [noteOnFrame, totalFrames]
std::int64_t totalFrames = 0; // frames in the captured file
std::int64_t leadInFrames = 0; // rendered ahead of the file's frame 0, then discarded
std::int64_t noteOnFrame = 0; // both in render frames
std::int64_t noteOffFrame = 0;
// The capture's root: rendering AT root is what makes the root survivable, which is
// why the root parameter is the one processing control a bake does not reset.
int note = 60;
int velocity = 100;
int sampleRate = 0;
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
};
// nullopt for a collapsed window, a non-positive rate, or a window that rounds to no
// frames — a degenerate buffer is refused rather than rendered. `rootNote` and the
// resolved velocity are clamped into MIDI range.
// nullopt for a collapsed window, a non-positive rate, a window that rounds to no frames,
// or one past kMaxBakeFrames — a buffer that is degenerate or unholdable is refused rather
// than rendered. `rootNote` and the resolved velocity are clamped into MIDI range.
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
int rootNote);
+22 -15
View File
@@ -11,16 +11,17 @@ namespace reasampler::instrument::bake {
namespace {
// A fixed render block, deliberately independent of the host's: the block boundary is
// where the engine observes live parameters and re-checks voice state, so pinning it here
// is what keeps two bakes of one dialed sound byte-identical on different hosts.
// A fixed render block rather than the host's. A block boundary is where the engine
// re-observes live state, and the detach below leaves it nothing to observe — so this is
// defence in depth against a future block-boundary read, not the reason two bakes agree.
constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
if (plan.leadInFrames < 0 || plan.renderFrames() > kMaxBakeFrames) return out;
// The live block is the audio thread's moving target; a render that observed it would
// depend on what the user happened to be dragging. The dialed values are already in
@@ -28,9 +29,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
sample.live = nullptr;
const int channels = sample.channelCount();
const auto total = static_cast<std::size_t>(plan.totalFrames);
std::vector<AudioSample> left(total, 0.f);
std::vector<AudioSample> right(channels == 2 ? total : 0u, 0.f);
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
std::vector<AudioSample> left(rendered, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
@@ -40,17 +41,18 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.totalFrames;) {
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
// Trigger ignores note-off by design; in Gate this is the release the programmed
// note length bounds.
if (pos == plan.noteOffFrame) engine.noteOff(plan.note);
// Stop the block at the next event frame so both land sample-accurately.
std::int64_t limit = plan.totalFrames;
if (pos < plan.noteOnFrame) limit = plan.noteOnFrame;
else if (pos < plan.noteOffFrame) limit = plan.noteOffFrame;
const std::int64_t chunk = std::min(limit - pos, kBlockFrames);
// Stop the block at the next event frame so both land sample-accurately. An event
// past the window (a capture that closes before the note) never bounds anything.
std::int64_t limit = plan.renderFrames();
if (pos < plan.noteOnFrame) limit = (std::min)(limit, plan.noteOnFrame);
else if (pos < plan.noteOffFrame) limit = (std::min)(limit, plan.noteOffFrame);
const std::int64_t chunk = (std::min)(limit - pos, kBlockFrames);
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
const auto at = static_cast<std::size_t>(pos);
@@ -62,10 +64,15 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
// A flat multiply, not the processor's per-sample ramp: the gain is constant for the
// whole render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[f];
if (channels == 2) out.interleaved[f * channels + 1] = right[f];
out.interleaved[f * channels] = left[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
}
return out;
}
+7 -8
View File
@@ -1,9 +1,8 @@
// bake_render — the offline pass: one programmed note through a voice engine built for
// this render alone, summed into an interleaved buffer.
//
// Never touches a live engine and never runs on the audio thread: it takes the SampleData
// BY VALUE precisely so it can detach the live-parameter block before rendering (see
// renderBake), which is what makes a repeated bake byte-identical.
// Never touches a live engine and never runs on the audio thread. Takes its SampleData BY
// VALUE for the reason this directory's CLAUDE.md records.
#pragma once
@@ -30,10 +29,10 @@ struct BakeAudio {
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path. The gate is held for the plan's note
// span and released at noteOffFrame — with the window itself bounding the render, a Gate
// sustain loop terminates by construction rather than by trusting the envelope to end.
// An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan);
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine, printed here because the bake's
// reset hands that control back at unity. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
} // namespace reasampler::instrument::bake
+9
View File
@@ -14,6 +14,15 @@ BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
out.params.keyTrack = dialed.keyTrack;
// There is no key-range parameter to carry (core/instrument/CLAUDE.md: no key-range
// concept) — if one is ever added it belongs on this list, not in the defaults.
// Play mode is on neither ratified list, so it is classified here, and the acceptance
// criteria decide it: the bake's product is a finished one-shot carrying its own
// attack, span and release. Trigger plays that back verbatim — note-off ignored, the
// default AHD flat at unity over the whole span. Gate would re-gate it: the default
// release would cut the printed tail at note-off, and every further iteration would cut
// the previous one's again. "Neutral" for this control means "adds no processing",
// which is Trigger, not the value struct's own Gate default.
out.params.play.playMode = PlayMode::Trigger;
return out;
}
+5 -7
View File
@@ -11,17 +11,15 @@
namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set, but it is post-mixer gain that the render printed, so it belongs to the
// same decision and is answered here rather than left to the shell.
// parameter set, but renderBake prints it into the file, so it belongs to the same decision
// and is answered here rather than left to the shell.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — the dialed gain is in the audio now
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
};
// Everything defaults; the survivors are copied back explicitly. That direction is
// deliberate: a parameter added later is presumptively part of what the render printed,
// and over-resetting a control costs a re-dial while under-resetting silently applies the
// same processing twice. A new MAPPING fact must be added to the copies below.
// Everything defaults; the survivors are copied back explicitly (this directory's CLAUDE.md
// owns why that direction, and which classifications are ratified).
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
} // namespace reasampler::instrument::bake
+3 -1
View File
@@ -72,7 +72,9 @@ sits on.
- `resample_name` — the display name a resample's new bank entry takes, so repeated bakes
read as one iteration chain (`Kick` -> `Kick r2` -> `Kick r3`) rather than as N unrelated
captures. Presentation only: the machine-readable lineage is the ledger's
`parentSampleId`, and nothing parses this name back into one.
`parentSampleId`, and nothing parses this name back into one. **The rule is per-NAME, not
per-bank** — two add-distinct bakes of the same source both land as `"<name> r2"`, so
display names are not unique and a browser reading the chain must read the ledger.
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** It is the recipe facet of the tracking system whose authority lives in `core/tracking`; the lineage facet (which file derives from which) is the ledger's, not the `Sample`'s.
## Gotchas
+3 -2
View File
@@ -44,8 +44,9 @@ This directory owns two cross-artifact contracts specifically:
namespace via `reaper_bridge::writeUsageExtState` — an entry point that is
**prefix-guarded** (accepts only `rsusage_`-prefixed keys, refuses all others), so the
read-only-bank invariant is structurally enforced. Direction: the instrument writes
usage keys; the extension reads them — the one sanctioned instrument→ext-state write,
a deliberate exception analogous to `assignment_request` on the other wire.
usage keys; the extension reads them — one of the two sanctioned instrument→ext-state
writes (`bake_wire`'s `rsbake_` request key is the other), a deliberate exception
analogous to `assignment_request` on the other wire.
Usage records are **never cleared by the instrument at teardown** (REAPER destroys the
plugin instance when an FX chain is set offline, including Design View's CPU-park, so a
terminate-time clear would strip a still-live instance's record); liveness is decided
+1
View File
@@ -26,6 +26,7 @@ BakeStatus statusFromWire(int raw) {
case BakeStatus::StagedMissing:
case BakeStatus::NoSource:
case BakeStatus::IndexRejected:
case BakeStatus::WrongProject:
return static_cast<BakeStatus>(raw);
}
return BakeStatus::Failed;
+5 -8
View File
@@ -2,14 +2,10 @@
// bake_wire — the resample bake's request/outcome pair, on ONE per-instance ext-state key
// ("rsbake_<instanceGuid>"). Pure: no REAPER/VST3/SWELL/vendor includes.
//
// The instrument writes a BakeRequest, invokes the extension's bake action synchronously,
// and reads the BakeOutcome the action wrote back over the same key. That is a call and a
// return in one UI tick — deliberately NOT the poller/nonce handshake this seam once
// spiked; nothing here may grow a claim protocol.
//
// `generation` exists for the same reason it does on assignment_request: a repeat bake of
// the same source is otherwise indistinguishable from a stale value, and an outcome whose
// generation does not echo the request is a leftover, not an answer.
// A call and a return in one UI tick — deliberately NOT the poller/nonce handshake this
// seam once spiked; nothing here may grow a claim protocol. `generation` is a wall-clock
// stamp: it distinguishes a repeat bake from a leftover, and dates a request the extension
// must refuse rather than answer to nobody.
#include <cstdint>
#include <optional>
@@ -52,6 +48,7 @@ enum class BakeStatus : int {
StagedMissing = 3, // the staged file was gone or unreadable
NoSource = 4, // the source capture is not in any bank
IndexRejected = 5, // the bank refused the add
WrongProject = 6, // the request belongs to a project tab this extension has not loaded
};
// What the extension did. On Ok the four sample fields describe the landed entry, so the
+5 -5
View File
@@ -11,11 +11,11 @@
// decisions (what to write on publish, which records count at prune time) are
// pure and provable without a DAW; shells only move strings.
//
// The INSTRUMENT writes usage keys, the EXTENSION only reads them — the one
// sanctioned instrument->ext-state write. It does not weaken the
// read-only-bank invariant: the instrument publishes only its own
// per-instance key, never banks/view/tail/assign; the bridge's write entry
// point structurally accepts only "rsusage_"-prefixed keys.
// The INSTRUMENT writes usage keys, the EXTENSION only reads them — one of the
// two sanctioned instrument->ext-state writes (bake_wire's request key is the
// other). It does not weaken the read-only-bank invariant: the instrument
// publishes only its own per-instance key, never banks/view/tail/assign; the
// bridge's write entry point structurally accepts only "rsusage_"-prefixed keys.
//
// Every failure, ambiguity, or uncertainty here fails safe toward PROTECT (the
// territory-wide asymmetry, stated in core/tracking/CLAUDE.md). Three folds enforce
+4 -3
View File
@@ -55,9 +55,10 @@ inline constexpr const char* kProjExtAssignKey = "assign_request";
// The INSTRUMENT writes one key per instance — "rsusage_<instanceGuid>" — carrying
// the sample_usage wire record of every capture that instance holds; the EXTENSION
// enumerates the prefix at prune-scan time so a held capture can never be pruned.
// This is the ONE sanctioned instrument-side ext-state write (it never mutates
// banks/view/tail/assign; the bridge's write entry point structurally accepts only
// this prefix). The "rs" qualifier keeps a future "usage_*"-prefixed key from being
// This is one of the TWO sanctioned instrument-side ext-state writes (the bake
// request below is the other; neither mutates banks/view/tail/assign, and the
// bridge's write entry points structurally accept only these two prefixes). The
// "rs" qualifier keeps a future "usage_*"-prefixed key from being
// swept into the FX-liveness fold. FOREVER-STABLE — changing the prefix strands
// every saved project's usage records (prune falls back to bank-references-only
// until instances republish).
+1 -1
View File
@@ -37,7 +37,7 @@ detail not covered there:
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: lands every pending `rsbake_*` request in the active project and answers over the same key, inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert".
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: lands every pending `rsbake_*` request in the active project and answers over the same key, inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header.
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
+98 -31
View File
@@ -6,11 +6,13 @@
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
@@ -51,6 +53,13 @@ using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
// The whole chain is a call and a return inside ONE editor tick, so a request older than
// this has no reader left: it is a crash leftover, and it is CLEARED rather than landed.
// Without the guard a stranded request would be banked on the next unrelated instance's
// click, and the outcome written back to a key nobody will collect would persist into the
// .rpp forever.
constexpr std::int64_t kMaxRequestAgeSeconds = 30;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
@@ -59,12 +68,6 @@ BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generati
return out;
}
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return projectDirOfRpp(std::string(buf.data()));
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
@@ -85,11 +88,12 @@ const Sample* findSourceSample(const BankBook& book, const std::string& sampleId
return nullptr;
}
// Lands ONE request. Mutates the book + ledger without persisting; the caller persists
// once for the batch. Every refusal path leaves the book untouched and writes no file, so
// a failed bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
const std::string projectDir = currentProjectDir();
// Lands ONE request into `session`'s book, which must already be the book of the project
// `projectDir` names. Mutates book + ledger without persisting; the caller persists once for
// the batch. Every refusal path leaves the book untouched and writes no file, so a failed
// bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
@@ -190,9 +194,9 @@ BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
: (book.index(bankId) &&
book.index(bankId)->add(landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — not the
// prune's deletion authority, which governs files the bank knows about. Leaving it
// would deposit an untracked orphan for every refused bake.
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
@@ -209,7 +213,7 @@ BakeOutcome landOne(ReaSamplerSession& session, const BakeRequest& request) {
return out;
}
// Every "rsbake_*" key in the project, keys only — the value can outgrow
// Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
std::vector<std::string> keys;
@@ -234,28 +238,82 @@ std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
return read.value;
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
}
return out;
}
// One answer to write back after the undo block closes.
struct Answer {
ReaProject* proj = nullptr;
std::string key;
std::string wire; // empty = clear the key instead of answering it
std::string console; // empty = nothing to print
};
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) return;
// Three projects have to agree before anything may land: the tab the request was found
// in, the tab whose book/ledger poll() last loaded, and the tab saveToActiveProject
// will persist into. Land on a disagreement and one tab's bake is written into
// another's bank. Whether REAPER makes Main_OnCommandEx's `proj` current for the
// action's duration is unverified in the DAW; this holds either way, and a request it
// cannot land is told why rather than silently ignored.
const void* loaded = session.loadedProject();
const void* active = EnumProjects(-1, nullptr, 0);
const bool sessionUsable = loaded != nullptr && loaded == active;
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers;
int landedCount = 0;
Undo_BeginBlock2(nullptr);
for (const std::string& key : pendingBakeKeys(proj)) {
const std::optional<std::string> raw = readKey(proj, key);
if (!raw) continue;
const std::optional<BakeRequest> request = wire::decodeBakeRequest(*raw);
// Not a request: an outcome this instance has not yet collected, or a value from a
// build we do not read. Leave it — the writing instance owns clearing its own key.
if (!request) continue;
for (const OpenProject& open : openProjects()) {
for (const std::string& key : pendingBakeKeys(open.proj)) {
const std::optional<std::string> raw = readKey(open.proj, key);
if (!raw) continue;
const std::optional<BakeRequest> request = wire::decodeBakeRequest(*raw);
// Not a request: an outcome this instance has not yet collected, or a value from
// a build we do not read. Leave it — the writing instance owns clearing its key.
if (!request) continue;
const BakeOutcome outcome = landOne(session, *request);
if (outcome.status == BakeStatus::Ok) ++landedCount;
SetProjExtState(proj, kProjExtNamespace(), key.c_str(),
wire::encodeBakeOutcome(outcome).c_str());
if (outcome.status != BakeStatus::Ok)
ShowConsoleMsg(("ReaSampler resample: " + outcome.message + ".\n").c_str());
// Stale (either direction, so a clock moved backwards is caught too): clear the
// key, never answer it. The instance that could read an answer is gone.
if (std::llabs(nowSec - request->generation) > kMaxRequestAgeSeconds) {
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome;
if (!sessionUsable || static_cast<const void*>(open.proj) != loaded) {
outcome = refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
request->generation);
} else {
outcome = landOne(session, open.dir, *request);
if (outcome.status == BakeStatus::Ok) ++landedCount;
}
answers.push_back(Answer{
open.proj, key, wire::encodeBakeOutcome(outcome),
outcome.status == BakeStatus::Ok
? std::string{}
: "ReaSampler resample: " + outcome.message + ".\n"});
}
}
if (landedCount > 0) {
@@ -266,10 +324,19 @@ void RunResampleBake(ReaSamplerSession& session) {
Undo_EndBlock2(nullptr,
persisted ? "ReaSampler: resample bake into bank" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
bankPanelRefresh();
} else {
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point
}
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
// undo point that captured it could restore the consumed request on the next Ctrl-Z —
// which the following bake would then re-land against a temp file that is long gone.
for (const Answer& answer : answers) {
SetProjExtState(answer.proj, kProjExtNamespace(), answer.key.c_str(),
answer.wire.c_str());
if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str());
}
if (landedCount > 0) bankPanelRefresh();
}
} // namespace reasampler::capture
+6 -4
View File
@@ -16,10 +16,12 @@ class ReaSamplerSession;
namespace reasampler::capture {
// The bake action's body: lands every pending "rsbake_*" request in the active project,
// one undo point for the batch. Normally there is exactly one — the instance that just
// invoked us. Runs synchronously inside the invoking instance's Main_OnCommandEx call, so
// the answer is available to it the moment this returns.
// The bake action's body: scans EVERY open project tab for pending "rsbake_*" requests and
// lands the ones belonging to the project this session has loaded, one undo point for the
// batch. Normally there is exactly one — the instance that just invoked us. Runs
// synchronously inside that instance's Main_OnCommandEx call, so the answer is available to
// it the moment this returns. A request from another tab is refused, and one too old to
// have a reader left is cleared rather than answered.
void RunResampleBake(ReaSamplerSession& session);
} // namespace reasampler::capture
+1 -1
View File
@@ -103,7 +103,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` against `getReaperParent(3)`, the instance's OWN project tab, never the focused one) and `projectTempoBpm`.
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
+9 -4
View File
@@ -121,14 +121,19 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const InstrumentParams dialed = processor.instrumentParams();
const int rootNote = dialed.rootOverride ? *dialed.rootOverride : source->rootNote;
const auto plan = planBake(resolveNote(defaultBakeProgram(), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
// The snapshot comes first: the default program's window is derived from the sound it
// carries (a Gate release, a Trigger play span), not from a constant.
std::optional<SampleData> snapshot = processor.bakeSnapshot();
if (!snapshot) return fail("the loaded capture could not be decoded for the render");
const instrument::bake::BakeAudio audio = renderBake(std::move(*snapshot), *plan);
const auto plan = planBake(
resolveNote(defaultBakeProgram(*snapshot, sampleRate, *tempo), *tempo), sampleRate,
rootNote);
if (!plan) return fail("the programmed capture window is empty");
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), *plan, processor.masterGainLinear());
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's
+3 -2
View File
@@ -15,8 +15,9 @@ class ReaSamplerProcessor;
class ReaperBridge;
// Whether a bake can run at all right now: a REAPER host, and the extension's bake action
// registered. Cheap enough for the editor's sync tick, which is where the button's paint
// state is decided — the control must never be enabled and then refuse.
// registered. Runs one NamedCommandLookup and builds one std::string per call — cost
// unmeasured. It is on the editor's sync tick because that is where the button's paint
// state is decided, and the control must never be enabled and then refuse.
bool bakeAvailable(ReaperBridge& bridge);
struct BakeChainResult {
+16 -3
View File
@@ -235,10 +235,15 @@ void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
}
setSelectedSampleId(entry.sampleId);
setInstrumentParams(reset);
setMasterGainLinear(masterGainLinear);
{
// ARMED, not stored: publishBuiltLocked applies it inside the same locked section as
// the pointer swap. Storing it here instead would put the reset gain under the OLD
// capture for the whole bridge-read-plus-WAV-decode the reload below runs first.
std::lock_guard<std::mutex> lock(reloadMutex_);
gainAtNextPublish_ = masterGainLinear;
}
// ONE reload for the re-point and the reset together: it decodes the new file and
// publishes the neutral parameters in the same swap, so no block is ever rendered with
// one of the two applied and not the other.
// publishes the neutral parameters in the same swap.
reloadInstrument();
}
@@ -292,6 +297,14 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
}),
graveyard_.end());
LoadedInstrument* prev = live_.exchange(built.release());
// A bake's reset gain lands here rather than at its call site, so the gain and the
// capture it belongs to become audible to process() in the same instant. A tail still
// ringing out of the drain does take the new gain — one gain sits above every snapshot,
// the same shape as ONE BLOCK, ONE RATE (see builtSampleRate_).
if (gainAtNextPublish_) {
setMasterGainLinear(*gainAtNextPublish_);
gainAtNextPublish_.reset();
}
LoadedInstrument* evicted = draining_.exchange(prev);
if (evicted) graveyard_.push_back(std::unique_ptr<LoadedInstrument>(evicted));
}
+3 -2
View File
@@ -155,8 +155,9 @@ bool ReaperBridge::invokeExtensionAction(const std::string& commandName) {
const int command = lookupCommand(commandName);
if (command == 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
// getReaperParent(3), not the focused tab: an instance in a background tab must bake
// into ITS OWN project's bank.
// getReaperParent(3), not the focused tab — the ask is that an instance in a background
// tab bakes into ITS OWN project's bank (see the header on what that does and does not
// guarantee).
void* proj = reaper->getReaperParent(3);
// flag 0 is the conventional "no modifier" value; the header documents no other, and
// the extension's hookcommand ignores it.
+9 -5
View File
@@ -71,11 +71,15 @@ public:
// affordance: an unavailable action must read Disabled, never enabled-then-refusing.
bool extensionActionAvailable(const std::string& commandName);
// Fires the action against THIS INSTANCE's own project tab (getReaperParent(3)), not
// whichever tab is focused. Returns false when the action is unregistered — the
// invocation itself reports nothing, so a caller learns the result from the state the
// action wrote, never from here. Must NOT be called from a mouse handler: it runs the
// extension's whole bake landing synchronously, and the action re-points this instance.
// Fires the action with THIS INSTANCE's own project tab (getReaperParent(3)) as
// Main_OnCommandEx's `proj`, rather than leaving it to whichever tab is focused. What
// REAPER then makes current for the action's duration is NOT verified in the DAW, so
// this is a request, not a guarantee — the extension side re-derives which project a
// request came from and refuses one it cannot safely land. Returns false when the action
// is unregistered; the invocation itself reports nothing, so a caller learns the result
// from the state the action wrote, never from here. Must NOT be called from a mouse
// handler: it runs the extension's whole bake landing synchronously, and the action
// re-points this instance.
bool invokeExtensionAction(const std::string& commandName);
// The effective project tempo (BPM, quarter notes per minute) at the edit cursor of
+8 -3
View File
@@ -11,6 +11,7 @@
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
@@ -130,9 +131,9 @@ public:
// Adopt a landed bake in ONE act: the new capture becomes this instance's ref and
// selection, the parameter set and master gain go neutral, and a single reload
// publishes both together. The ordering is the whole point — a reset published ahead
// of the re-point would put neutral parameters under the OLD capture for a block.
// UI thread only.
// publishes all three together. The ordering is the whole point — anything published
// ahead of the re-point would apply neutral settings to the OLD capture, which is the
// one thing they are meaningless against. UI thread only.
void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
double masterGainLinear);
@@ -304,6 +305,10 @@ private:
std::atomic<std::uint64_t> drainIdleGeneration_{0};
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// A master gain that must reach the audio thread in the SAME swap as the next publish —
// the bake's reset, which the old capture would be the wrong thing to apply it to.
// Guarded by reloadMutex_, consumed by publishBuiltLocked.
std::optional<double> gainAtNextPublish_;
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.
+3 -1
View File
@@ -14,7 +14,9 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
- **Prune is the single, exclusive file-deletion authority.** No bank op, no
capture op, no Design View op deletes a file; if any path other than prune
deletes a bank file, reject it in review.
deletes a bank file, reject it in review. The ONE carve-out — a shell removing
a file it wrote itself moments earlier that no index ever referenced, wherever
it sits — is stated in full at `prune_fs.cpp`'s header and nowhere else.
- **Dry-run first, always; no silent deletion.** Prune reports before it deletes
(orphan count, reclaimed size, and — for a small set — the files); actual
deletion is a confirmed second step. No periodic/background sweep.
+13 -7
View File
@@ -2,13 +2,19 @@
//
// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove
// on SWELL platforms) is the ONLY code in the system that deletes USER files —
// the sole deletion authority over the bank folder's bytes (a shell removing a
// transient scratch file it just created, e.g. the drop path's temp
// .vstpreset, is self-cleanup, not authority over user data). Deliberately
// file-local (anonymous namespace): nothing outside this TU can reach it, and
// this concentration must never spread. The safety-critical "which files are
// orphans" decision stays in the pure core (prune_reconcile); this TU only
// enumerates, resolves, stats, and — after the confirm — executes.
// the sole deletion authority over the bank folder's bytes. THE carve-out, and
// its one home: a shell removing a file it wrote itself moments earlier and
// that no index ever referenced is self-cleanup, not authority over user data.
// It covers a transient scratch file outside the bank (the drop path's temp
// .vstpreset) AND a bank-folder write whose index entry was then refused
// (bake_land) — the discriminator is "did this call create it, and did anything
// ever reference it", not where it sits.
//
// deleteOrphanFile is deliberately file-local (anonymous namespace): nothing
// outside this TU can reach it, and this concentration must never spread. The
// safety-critical "which files are orphans" decision stays in the pure core
// (prune_reconcile); this TU only enumerates, resolves, stats, and — after the
// confirm — executes.
//
// Compiled into the reaper_reasampler module. REAPER-facing only through the
// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly.
+8 -2
View File
@@ -7,8 +7,7 @@
// JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h).
// * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune
// scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank
// folder. Nothing else in the system deletes bank-folder bytes (a shell's
// self-cleanup of its own transient scratch file is not this authority).
// folder, plus the one self-cleanup carve-out from it, both stated there.
//
// Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext
// state lives inside the .rpp, so the index travels with the project for
@@ -64,6 +63,13 @@ public:
model::BankModel& bank() { return book_.activeIndex(); }
const model::BankModel& bank() const { return book_.activeIndex(); }
// The project whose book/view/tail/ledger poll() last loaded — compare-only, never
// dereferenced; nullptr before the first poll. An action that can be fired against a
// project other than the one currently loaded here (the VST bake, which targets its own
// instance's tab) MUST check this before mutating: the book in memory belongs to one
// project, and landing another tab's request would write its capture into this bank.
const void* loadedProject() const { return lastProject_; }
// Design-View model; persists MODEL STATE only (visibility on open is the view shell's job).
ViewModeModel& view() { return view_; }
const ViewModeModel& view() const { return view_; }