Ξ-W2-T1: the resample bake chain — instrument renders, extension banks, one click re-points and resets

This commit is contained in:
2026-08-01 16:26:28 -04:00
parent 6c982cd617
commit 60308a3655
52 changed files with 2212 additions and 55 deletions
+5 -1
View File
@@ -10,6 +10,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
@@ -44,7 +45,10 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
# renders its own bake in its own process, which is what keeps the extension's link graph
# free of the voice engine — a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
+9
View File
@@ -26,6 +26,8 @@
#include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
#include "shell/capture/bake_land.h" // resample-bake landing action body
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
@@ -86,6 +88,7 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session)
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops
// the console window).
@@ -131,6 +134,12 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
&RunCancelRealtime});
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource});
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
// stranded request can be landed by hand). The suffix is the wire contract itself —
// core/wire/bake_wire owns the spelling both artifacts read.
rows.push_back({reasampler::wire::kBakeActionSuffix,
"land pending ReaSampler 9000 resample bake",
&RunResampleBake});
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows;
+5 -2
View File
@@ -1,8 +1,8 @@
# src/core/instrument — pure VST3-instrument core (engine / map / note / ui)
# src/core/instrument — pure VST3-instrument core (bake / engine / map / note / ui)
## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in four
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in five
subdirectories:
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
@@ -14,6 +14,9 @@ subdirectories:
- **`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.
- **`bake/`** — the resample bake's pure half: the programmed note resolved to a frame
window, the offline render over a voice engine built for that render alone, and the
ratified post-bake reset. See `bake/CLAUDE.md`.
- **`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
+2
View File
@@ -2,6 +2,8 @@ add_subdirectory(engine)
add_subdirectory(map)
add_subdirectory(note)
add_subdirectory(ui)
# Last: bake composes the three above it.
add_subdirectory(bake)
# The spline EG spans all three: the shared curve + its RT cursor (engine), the dual-state
# persistence (map), and the point-editing grammar (ui). Declared here because no one
+51
View File
@@ -0,0 +1,51 @@
# src/core/instrument/bake — the resample bake's pure half
## Scope
The offline pass that turns the dialed instrument into a file, and the reset that hands the
instrument back neutral afterwards. A fifth peer of `engine/` / `map/` / `note/` / `ui/`
under `core/instrument/`, pure by the same rule — no REAPER types, no VST3 types, no host.
It is neither engine (it owns no voice), mapping (it resolves no capture), nor note (it
holds no program): it is the *composition* of the three into one render, plus the one
decision about what the render made obsolete.
## Invariants
- **The bake renders on its OWN engine, never the live one.** `renderBake` takes its
`SampleData` BY VALUE and detaches `SampleData::live` before constructing a `VoiceEngine`
for the render alone. Two consequences, both load-bearing: the audio thread's live block
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 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 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.
## 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_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
both the parameter set and the post-mixer master gain.
## 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.
- 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.
+13
View File
@@ -0,0 +1,13 @@
# 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)
reasampler_test(bake_plan LINK bake_plan)
reasampler_pure_library(bake_render
SOURCES bake_render.cpp
LINK PUBLIC bake_plan sampler_core)
reasampler_test(bake_render LINK bake_render)
# sample_map carries InstrumentParams, which is the whole of what a reset rewrites.
reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map)
reasampler_test(bake_reset LINK bake_reset)
+53
View File
@@ -0,0 +1,53 @@
// See bake_plan.h.
#include "core/instrument/bake/bake_plan.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::bake {
using note::NoteProgram;
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)));
}
} // namespace
NoteProgram defaultBakeProgram() {
NoteProgram p;
p.end = note::EndOffset(note::offsetFromMs(kDefaultReleaseTailMs));
return p;
}
std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
int rootNote) {
if (resolved.windowCollapsed) return std::nullopt;
if (sampleRate <= 0) return std::nullopt;
BakePlan plan;
plan.sampleRate = sampleRate;
plan.totalFrames = toFrames(resolved.captureLengthSeconds(), sampleRate);
if (plan.totalFrames <= 0) 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.note = std::clamp(rootNote, 0, 127);
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
return plan;
}
} // namespace reasampler::instrument::bake
+45
View File
@@ -0,0 +1,45 @@
// 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.
//
// 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.
#pragma once
#include <cstdint>
#include <optional>
#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 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 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.
struct BakePlan {
std::int64_t totalFrames = 0;
std::int64_t noteOnFrame = 0; // in [0, totalFrames]
std::int64_t noteOffFrame = 0; // in [noteOnFrame, totalFrames]
// 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;
};
// 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.
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
int rootNote);
} // namespace reasampler::instrument::bake
+73
View File
@@ -0,0 +1,73 @@
// See bake_render.h.
#include "core/instrument/bake/bake_render.h"
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/voice_engine.h"
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.
constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) 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
// this SampleData's own play params, which is what the bake is meant to print.
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);
// 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.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.totalFrames;) {
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);
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
const auto at = static_cast<std::size_t>(pos);
const auto n = static_cast<std::size_t>(chunk);
if (channels == 2) engine.render(left.data() + at, right.data() + at, n);
else engine.render(left.data() + at, n);
pos += chunk;
}
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
out.interleaved.resize(total * static_cast<std::size_t>(channels));
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];
}
return out;
}
} // namespace reasampler::instrument::bake
+39
View File
@@ -0,0 +1,39 @@
// 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.
#pragma once
#include <cstdint>
#include <vector>
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/engine/play_params.h"
namespace reasampler::instrument::bake {
using audio::AudioSample;
struct BakeAudio {
std::vector<AudioSample> interleaved; // [f0c0, f0c1, f1c0, …]
int channelCount = 0; // 0 = nothing rendered
int sampleRate = 0;
std::int64_t frameCount() const {
return channelCount > 0
? static_cast<std::int64_t>(interleaved.size()) / channelCount
: 0;
}
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);
} // namespace reasampler::instrument::bake
+20
View File
@@ -0,0 +1,20 @@
// See bake_reset.h.
#include "core/instrument/bake/bake_reset.h"
namespace reasampler::instrument::bake {
BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
BakeReset out;
// The root is what the note was rendered at, so it is exactly what the new capture
// plays back at unity — resetting it would detune every following iteration.
out.params.rootOverride = dialed.rootOverride;
// How far pitch tracks the keyboard is a fact about the mapping; a single rendered
// note carries no trace of it.
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.
return out;
}
} // namespace reasampler::instrument::bake
+27
View File
@@ -0,0 +1,27 @@
// bake_reset — hand the instrument back neutral after a bake: the dialed processing now
// lives in the recaptured audio, so the controls that produced it return to their defaults.
//
// The rule, ratified by Daniel: a control resets iff its effect is in the printed audio; a
// MAPPING fact survives, because it describes how the file is played, not how it was made.
#pragma once
#include "core/instrument/map/sample_map.h" // InstrumentParams
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.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — the dialed gain is in the audio now
};
// 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.
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
} // namespace reasampler::instrument::bake
+6 -2
View File
@@ -42,7 +42,7 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
const auto topFor = [&row](int h) { return row.y + (row.height - h) / 2; };
const auto leftOf = [&row](int edge, int w) { return std::max(row.x, edge - w); };
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview. The
// The fixed run, right to left: Browse, Mono|Stereo, velocity cell, preview, bake. The
// velocity-curve button that used to sit here now lives in the deck's VELOCITY group.
const int navH = std::min(kRunButtonH, row.height);
const int navTop = topFor(navH);
@@ -72,9 +72,13 @@ ChromeRects chromeRects(const Rect& chrome, int knobSize) {
r.preview = Rect::ltrb(leftOf(prevRight, kPreviewBtnW), prevTop, prevRight,
prevTop + std::min(kRunButtonH, row.height));
const int bakeRight = leftOf(r.preview.x, kRunGap);
r.bake = Rect::ltrb(leftOf(bakeRight, kBakeButtonWidth), prevTop, bakeRight,
prevTop + std::min(kRunButtonH, row.height));
// The title takes what the run leaves; clamped so a narrow window collapses it rather
// than inverting it.
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.preview.x - kRunGap),
r.title = Rect::ltrb(row.x + kPad, row.y, std::max(row.x + kPad, r.bake.x - kRunGap),
row.bottom());
if (r.controls.empty()) return r;
+3 -1
View File
@@ -9,6 +9,7 @@
namespace reasampler::instrument::ui {
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
inline constexpr int kBakeButtonWidth = 54; // the resample-bake trigger
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
// derive them differently. The toolbar's fixed run is right-anchored and the title takes
@@ -17,7 +18,8 @@ inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
struct ChromeRects {
Rect toolbar; // full-width top row
Rect title; // the title text slot: the toolbar left of the control run
Rect preview; // ---- the right-anchored run, left to right ----
Rect bake; // ---- the right-anchored run, left to right ----
Rect preview;
Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob;
Rect velLabel;
+4
View File
@@ -69,6 +69,10 @@ sits on.
- `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface.
- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`.
- `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.
- `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
+4
View File
@@ -9,6 +9,10 @@ reasampler_pure_library(bank_book
LINK PUBLIC bank_model slot_map PRIVATE json)
reasampler_test(bank_book LINK bank_book)
# Links nothing: a display name is a string rule, not a bank operation.
reasampler_pure_library(resample_name SOURCES resample_name.cpp)
reasampler_test(resample_name LINK resample_name)
reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire)
# bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip.
reasampler_test(provenance LINK provenance bank_model)
+49
View File
@@ -0,0 +1,49 @@
// See resample_name.h.
#include "core/model/resample_name.h"
#include <cctype>
namespace reasampler::model {
namespace {
constexpr const char* kFallbackStem = "resample";
constexpr int kFirstIteration = 2; // the source itself is iteration 1
// The " r<digits>" tail, if the name ends in one and the digits are a whole number > 0.
// Returns 0 (no tail) otherwise; `stemLength` is then left untouched.
int trailingIteration(const std::string& name, std::size_t& stemLength) {
std::size_t digitsBegin = name.size();
while (digitsBegin > 0 && std::isdigit(static_cast<unsigned char>(name[digitsBegin - 1])))
--digitsBegin;
if (digitsBegin == name.size()) return 0; // no digits at the end
if (digitsBegin < 2) return 0; // no room for " r"
if (name[digitsBegin - 1] != 'r' || name[digitsBegin - 2] != ' ') return 0;
// Overflow-safe accumulate: a pathological digit run stops counting rather than
// wrapping into a small number and silently reusing an existing name.
int value = 0;
for (std::size_t i = digitsBegin; i < name.size(); ++i) {
if (value > 1000000) return 0;
value = value * 10 + (name[i] - '0');
}
if (value <= 0) return 0;
stemLength = digitsBegin - 2;
return value;
}
} // namespace
std::string nextIterationName(const std::string& sourceName) {
if (sourceName.empty())
return std::string(kFallbackStem) + " r" + std::to_string(kFirstIteration);
std::size_t stemLength = sourceName.size();
const int current = trailingIteration(sourceName, stemLength);
if (current > 0)
return sourceName.substr(0, stemLength) + " r" + std::to_string(current + 1);
return sourceName + " r" + std::to_string(kFirstIteration);
}
} // namespace reasampler::model
+19
View File
@@ -0,0 +1,19 @@
#pragma once
// resample_name — the display name a resample's new bank entry takes, so a repeated bake
// reads as one iteration chain in the browser rather than as N unrelated captures.
//
// The chain is legible in the NAME only; the machine-readable lineage is
// OriginRecord::parentSampleId, written at birth. This is presentation, and deliberately
// not parsed back into a lineage by anything.
#include <string>
namespace reasampler::model {
// "Kick" -> "Kick r2" -> "Kick r3". A name already carrying an " r<N>" tail increments it
// rather than stacking a second one; an empty name becomes "resample r2". A tail that is
// not a whole positive number (" r", " r0", " rx") is left alone and a fresh " r2"
// appended, because it was never one of ours.
std::string nextIterationName(const std::string& sourceName);
} // namespace reasampler::model
+4 -1
View File
@@ -72,7 +72,10 @@ birth record is written for every system-created file, ambiguous parentage or no
- `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and
held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict)
and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the
asker's own usage key). Both read one borrowed `TrackingState`.
asker's own usage key). Both read one borrowed `TrackingState`. `resampleLanding` is
the resample's branch off that answer: `Replace` only on a definite `No`, since
`AddDistinct` disturbs no existing holder and is therefore the non-destructive side of
this question — `Indeterminate` lands with `Yes`.
## Gotchas
+4
View File
@@ -42,4 +42,8 @@ Answer tiedUsageExists(const TrackingState& state, const std::string& capturePat
return Answer::No;
}
Landing resampleLanding(Answer answer) {
return answer == Answer::No ? Landing::Replace : Landing::AddDistinct;
}
} // namespace reasampler::tracking
+9
View File
@@ -62,4 +62,13 @@ enum class Answer { No, Yes, Indeterminate };
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
const std::string& ownUsageKey);
// What a resample does with its result: take over the source's bank entry, or land beside
// it as a new one.
enum class Landing { Replace, AddDistinct };
// Replace only on a definite No. `Indeterminate` takes the same branch as `Yes` because
// AddDistinct disturbs no existing holder — it is the non-destructive side of this
// question, which is the rule the whole directory is written to.
Landing resampleLanding(Answer answer);
} // namespace reasampler::tracking
+1
View File
@@ -79,6 +79,7 @@ This directory owns two cross-artifact contracts specifically:
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
+5
View File
@@ -4,6 +4,11 @@ reasampler_test(wire LINK wire)
reasampler_pure_library(assignment_request SOURCES assignment_request.cpp LINK PRIVATE wire)
reasampler_test(assignment_request LINK assignment_request)
# app_version is PUBLIC: the action's lookup name is channel-qualified, so a consumer that
# resolves the action reads the same channel identity the extension registered under.
reasampler_pure_library(bake_wire SOURCES bake_wire.cpp LINK PRIVATE wire PUBLIC app_version)
reasampler_test(bake_wire LINK bake_wire)
reasampler_pure_library(sample_usage SOURCES sample_usage.cpp LINK PRIVATE wire)
# prune_reconcile composes the protection proof at the pure layer: a capture held by a live
# instance lands in the referenced union, so pruneOrphans can never emit it.
+124
View File
@@ -0,0 +1,124 @@
// bake_wire.cpp — see bake_wire.h. Pure: standard library only.
#include "core/wire/bake_wire.h"
#include "core/version/app_version.h"
#include "core/wire/wire.h"
namespace reasampler::wire {
namespace {
constexpr const char* kRequestMagic = "rsbakereq1";
constexpr const char* kOutcomeMagic = "rsbakeout1";
using wire::putField;
using Cursor = wire::Cursor;
// An unrecognized integer is `Failed`, not a parse error: the two artifacts ship
// independently, and a newer extension naming a failure this build has no word for must
// still read as a failure rather than as Ok (which is what the numeric default would be).
BakeStatus statusFromWire(int raw) {
switch (static_cast<BakeStatus>(raw)) {
case BakeStatus::Ok:
case BakeStatus::Failed:
case BakeStatus::NoProject:
case BakeStatus::StagedMissing:
case BakeStatus::NoSource:
case BakeStatus::IndexRejected:
return static_cast<BakeStatus>(raw);
}
return BakeStatus::Failed;
}
} // namespace
std::string bakeActionLookupName() {
return "_" + version::channelCommandId(kBakeActionSuffix);
}
bool BakeRequest::operator==(const BakeRequest& o) const {
return instanceGuid == o.instanceGuid && stagedFilePath == o.stagedFilePath &&
sourceSampleId == o.sourceSampleId &&
sourceRelativePath == o.sourceRelativePath &&
sourceDisplayName == o.sourceDisplayName && ownUsageKey == o.ownUsageKey &&
rootNote == o.rootNote && generation == o.generation;
}
bool BakeOutcome::operator==(const BakeOutcome& o) const {
return status == o.status && sampleId == o.sampleId &&
relativePath == o.relativePath && displayName == o.displayName &&
rootNote == o.rootNote && channelCount == o.channelCount &&
replaced == o.replaced && message == o.message && generation == o.generation;
}
std::string encodeBakeRequest(const BakeRequest& req) {
std::string out = kRequestMagic;
putField(out, req.instanceGuid);
putField(out, req.stagedFilePath);
putField(out, req.sourceSampleId);
putField(out, req.sourceRelativePath);
putField(out, req.sourceDisplayName);
putField(out, req.ownUsageKey);
putField(out, std::to_string(req.rootNote));
putField(out, std::to_string(req.generation));
return out;
}
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kRequestMagic)) return std::nullopt;
BakeRequest req;
if (!cur.field(req.instanceGuid)) return std::nullopt;
if (!cur.field(req.stagedFilePath)) return std::nullopt;
if (!cur.field(req.sourceSampleId)) return std::nullopt;
if (!cur.field(req.sourceRelativePath)) return std::nullopt;
if (!cur.field(req.sourceDisplayName)) return std::nullopt;
if (!cur.field(req.ownUsageKey)) return std::nullopt;
if (!cur.fieldInt(req.rootNote)) return std::nullopt;
if (!cur.fieldInt64(req.generation)) return std::nullopt;
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
return req;
}
std::string encodeBakeOutcome(const BakeOutcome& outcome) {
std::string out = kOutcomeMagic;
putField(out, std::to_string(static_cast<int>(outcome.status)));
putField(out, outcome.sampleId);
putField(out, outcome.relativePath);
putField(out, outcome.displayName);
putField(out, std::to_string(outcome.rootNote));
putField(out, std::to_string(outcome.channelCount));
putField(out, outcome.replaced ? "1" : "0");
putField(out, outcome.message);
putField(out, std::to_string(outcome.generation));
return out;
}
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kOutcomeMagic)) return std::nullopt;
BakeOutcome outcome;
int rawStatus = 0;
std::string replaced;
if (!cur.fieldInt(rawStatus)) return std::nullopt;
if (!cur.field(outcome.sampleId)) return std::nullopt;
if (!cur.field(outcome.relativePath)) return std::nullopt;
if (!cur.field(outcome.displayName)) return std::nullopt;
if (!cur.fieldInt(outcome.rootNote)) return std::nullopt;
if (!cur.fieldInt(outcome.channelCount)) return std::nullopt;
if (!cur.field(replaced)) return std::nullopt;
if (!cur.field(outcome.message)) return std::nullopt;
if (!cur.fieldInt64(outcome.generation)) return std::nullopt;
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
if (replaced != "0" && replaced != "1") return std::nullopt;
outcome.status = statusFromWire(rawStatus);
outcome.replaced = (replaced == "1");
return outcome;
}
} // namespace reasampler::wire
+85
View File
@@ -0,0 +1,85 @@
#pragma once
// 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.
#include <cstdint>
#include <optional>
#include <string>
namespace reasampler::wire {
// The extension action the instrument invokes, as its command-id suffix. FOREVER-STABLE
// per channel like every other registered suffix. Both artifacts read this one symbol.
inline constexpr const char* kBakeActionSuffix = "RESAMPLE_BAKE";
// The NamedCommandLookup spelling of that action: a LEADING UNDERSCORE the
// rec->Register("command_id", …) string itself does not carry. The one place that
// underscore is written.
std::string bakeActionLookupName();
// What the instrument asks for. Every path is the instrument's own knowledge: it staged
// the file, it knows which capture it was resampling, and it knows its own usage key.
struct BakeRequest {
std::string instanceGuid; // the asking instance ("rsbake_<guid>" names the key)
std::string stagedFilePath; // ABSOLUTE, outside the bank folder; the instrument deletes it
std::string sourceSampleId; // the capture being resampled (lineage parent)
std::string sourceRelativePath; // its project-relative path — the tie query's subject
std::string sourceDisplayName; // what the new entry's name is derived from
std::string ownUsageKey; // "rsusage_<guid>" — excluded from the tie scan
int rootNote = 60;
std::int64_t generation = 0;
bool operator==(const BakeRequest& o) const;
bool operator!=(const BakeRequest& o) const { return !(*this == o); }
};
// Why a bake did not land. Values are WIRE INTEGERS — never renumber, only append; an
// unrecognized value decodes as `Failed` so a newer extension's vocabulary cannot make an
// older instrument read a failure as a success.
enum class BakeStatus : int {
Ok = 0,
Failed = 1, // generic / unrecognized
NoProject = 2, // unsaved project: the bank has no location
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
};
// What the extension did. On Ok the four sample fields describe the landed entry, so the
// instance can adopt it without a bank read — the same self-contained discipline the
// SampleRefs table exists for.
struct BakeOutcome {
BakeStatus status = BakeStatus::Failed;
std::string sampleId;
std::string relativePath;
std::string displayName;
int rootNote = 60;
int channelCount = 0;
bool replaced = false; // false = a distinct entry was added
std::string message; // human-readable, for the console
std::int64_t generation = 0; // echoes the request's
bool operator==(const BakeOutcome& o) const;
bool operator!=(const BakeOutcome& o) const { return !(*this == o); }
};
// Length-prefixed fields behind a magic+version tag, the house idiom, so arbitrary bytes
// in a path or a name round-trip whole.
std::string encodeBakeRequest(const BakeRequest& req);
std::string encodeBakeOutcome(const BakeOutcome& outcome);
// std::nullopt on malformed/truncated/trailing-garbage input, and on a tag this build does
// not read — a future version's record is refused rather than half-parsed. Round-trips.
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire);
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire);
} // namespace reasampler::wire
+12
View File
@@ -68,4 +68,16 @@ inline std::string usageKeyFor(const std::string& instanceGuid) {
return std::string(kProjExtUsageKeyPrefix) + instanceGuid;
}
// The resample bake's one key per asking instance — "rsbake_<instanceGuid>". The
// INSTRUMENT writes the request here and the EXTENSION writes its outcome back over the
// same key, within one synchronous action invocation; the instrument then clears it. The
// second of the two prefixes the instrument may write (the guard itself lives in
// reaper_bridge.h). Nothing durable is keyed off this spelling, but a stale value from a
// crashed session must still decode or be ignored — so treat it as fixed anyway.
inline constexpr const char* kProjExtBakeKeyPrefix = "rsbake_";
inline std::string bakeKeyFor(const std::string& instanceGuid) {
return std::string(kProjExtBakeKeyPrefix) + instanceGuid;
}
} // namespace reasampler
+1
View File
@@ -37,6 +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".
- `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).
+275
View File
@@ -0,0 +1,275 @@
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h"
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
namespace {
namespace fs = std::filesystem;
using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
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;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
std::string& bankIdOut) {
for (const Bank& b : book.banks()) {
if (const Sample* s = b.index.query(sampleId)) {
bankIdOut = b.id;
return s;
}
}
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();
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
const std::vector<std::uint8_t> bytes = util::readFileBytes(request.stagedFilePath);
if (bytes.empty())
return refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid || layout.frameCount() == 0)
return refuse(BakeStatus::StagedMissing, "the staged render is not a usable WAV",
request.generation);
BankBook& book = session.book();
std::string bankId;
const Sample* source = findSourceSample(book, request.sourceSampleId, bankId);
if (!source)
return refuse(BakeStatus::NoSource, "the resampled capture is not in any bank",
request.generation);
// Copied, not aliased: every mutation below invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
const bool replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(bytes);
BakeOutcome out;
out.generation = request.generation;
out.rootNote = request.rootNote;
out.channelCount = static_cast<int>(layout.channelCount);
out.replaced = replace;
if (!replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later.
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
return out;
}
}
}
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
// Seconds alone are not unique enough here: two bakes of the same source inside one
// second would derive the same file name, and the second would overwrite a file the
// first had just indexed. The content hash separates them, and leaves a re-bake of
// identical bytes idempotent rather than duplicated.
const std::string uniqueTag =
std::to_string(nowSec) +
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
std::error_code ec;
fs::create_directories(paths.absoluteDir, ec); // idempotent; the write reports failure
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
if (!writeFileBytes(destPath, bytes))
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
request.generation);
Sample landed;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
landed.id = replace ? sourceCopy.id
: ("bake-" + uniqueTag + "-" + paths.fileName);
landed.displayName =
replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
landed.relativePath = paths.relativePath; // project-relative (invariant)
landed.channelCount = static_cast<int>(layout.channelCount);
landed.sampleRate = static_cast<int>(layout.sampleRate);
landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
landed.rootNote = request.rootNote; // rendered AT root — that is what makes it survive
landed.tier = model::Tier::Scratch;
landed.contentHash = contentHash;
landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
const bool indexed = replace
? book.updateSampleInPlace(sourceCopy.id, landed)
: (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.
fs::remove(destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
: "the bank refused the new capture",
request.generation);
}
session.recordCreated(landed, tracking::OriginKind::Capture);
out.status = BakeStatus::Ok;
out.sampleId = landed.id;
out.relativePath = landed.relativePath;
out.displayName = landed.displayName;
out.message = replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// Every "rsbake_*" key in the 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;
const std::string prefix = kProjExtBakeKeyPrefix;
char keyBuf[256];
for (int idx = 0;; ++idx) {
keyBuf[0] = '\0';
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
break;
const std::string key(keyBuf);
if (key.compare(0, prefix.size(), prefix) == 0) keys.push_back(key);
}
return keys;
}
std::optional<std::string> readKey(ReaProject* proj, const std::string& key) {
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
});
if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt;
return read.value;
}
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (!proj) return;
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;
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());
}
if (landedCount > 0) {
// A bake changes what a live instance would play, so the generation bump rides the
// persist — every other open instance refreshes hands-free.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject();
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
}
}
} // namespace reasampler::capture
+25
View File
@@ -0,0 +1,25 @@
#pragma once
// bake_land — the EXTENSION's half of the resample chain: take the file a ReaSampler 9000
// instance staged outside the bank, land it as a bank capture, and answer over the same
// per-instance key the request arrived on.
//
// Renders nothing (the instrument already did, through its own engine, in its own process
// — which is what makes the bake the sound the user approved). Writes a file into the bank
// folder and an index entry, and NOTHING else: no timeline item, and no deletion — the
// superseded audio survives until a prune reclaims it.
#include <string>
namespace reasampler {
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.
void RunResampleBake(ReaSamplerSession& session);
} // namespace reasampler::capture
+16 -3
View File
@@ -83,8 +83,12 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
**Non-goals / guardrails.**
- The instrument never captures and never inserts into the arrange. Playback is a
read-only act over the bank. Any instrument path that captures, places a timeline
item, or writes back into the bank is a bug.
read-only act over the bank. Any instrument path that places a timeline item, or that
writes bank state itself, is a bug. **The resample bake is not an exception to that
and does not widen it:** the instrument RENDERS its own sound and REQUESTS a landing;
the extension is what captures the file into the bank and writes the index. The
instrument's whole outbound surface is one prefix-guarded request key plus one action
id — see `instrument_bake` and `reaper_bridge` below.
- The instrument never ingests. Capture, import, and drop-ingest are *extension*
acts; the instrument only reads and plays. A drop onto the editor window (if ever
shipped) is relayed to the extension as an ingest request — the instrument never
@@ -99,17 +103,26 @@ 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. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
- `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`.
- `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).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules, stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
## Gotchas
- **The bake click only ARMS; the editor's sync tick runs it.** Calling
`Main_OnCommandEx` inline from `WM_LBUTTONDOWN` would run the extension's whole landing
nested inside a mouse handler with `SetCapture` held, while the invoked action re-points
the very instance whose frame is on the stack. Deferring by one tick is same-thread and
in-instance — it is NOT a cross-process poller/nonce handshake, and it must not grow into
one.
- The bake's availability probe runs on the SAME tick that paints the button, so the
control can never be enabled on one tick and refuse on the next.
- `editor_internal.h` is include-only — it has no TU of its own and must never become
a public seam; only the `reasampler_editor` band-axis TUs include it.
- **The editor window class carries `CS_DBLCLKS`, which REPLACES the second button-down of
+3 -1
View File
@@ -66,6 +66,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
editor_input_curve.cpp
editor_stroke.cpp
editor_platform.cpp
instrument_bake.cpp
reasampler_embed.cpp
reaper_bridge.cpp
# draw_kit is compiled into each module rather than being a static library see root
@@ -88,7 +89,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
file_bytes curve_law stroke_aa)
file_bytes curve_law stroke_aa
bake_plan bake_render bake_reset bake_wire wav_codec)
# SDK_INC gives the REAPER VST3 interfaces + API header for the bridge; WDL_INC gives
# LICE for the editor. The VST3 SDK headers arrive via vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${REASAMPLER_SRC_DIR} ${SDK_INC} ${WDL_INC})
@@ -40,6 +40,14 @@ bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
invalidate();
return true;
}
// Resample bake. Arm only: onSyncTimer runs it a tick later, off this handler's stack
// and with no mouse capture held (instrument_bake.h says why that matters). A click
// while the extension is absent is swallowed — the button already paints Disabled.
if (contains(cr.bake, x, y)) {
if (bakeAvailable_) bakePending_ = true;
invalidate();
return true;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
@@ -109,6 +117,7 @@ ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
if (contains(cr.bake, x, y)) return {HoverKind::kBake, -1};
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
@@ -112,6 +112,9 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
} else {
title += " [host: no bridge]";
}
// A bake outcome outranks the identity readout while it lasts: the click's only other
// feedback is the sound itself, which is by design indistinguishable from before.
if (bakeMessageTicks_ > 0 && !bakeMessage_.empty()) title = bakeMessage_;
kitText(bmp, cr.title, title.c_str(), kToolbarFont, Role::TextPrimary);
// Browse: the picker. When nothing is loaded it is the empty state's dominant
@@ -128,6 +131,20 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
// nothing picked there is no root, no preview and no channel decision to make.
if (empty) return;
// Resample bake. Disabled without the extension: the bank is the extension's surface,
// so with it absent there is no writer and the control must read unavailable rather
// than accept a click it cannot honour.
{
const KitButtonBox box{toKitBox(cr.bake)};
const InteractionState st =
!bakeAvailable_ ? InteractionState::Disabled
: (bakePending_ ? InteractionState::Active
: (isHovered(HoverKind::kBake, -1)
? InteractionState::Hover
: InteractionState::Rest));
drawButton(bmp, box, "Bake", st, /*warn=*/false);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
// A drawn play triangle rather than a label or an embedded image: it inherits the button's
// own foreground role, so it stays legible in every interaction state at no build cost.
+27
View File
@@ -19,6 +19,7 @@
#include "ext_keys.h"
#include "core/instrument/engine/loop/loop_span.h" // defaultLoopBounds (the shared ghost span)
#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (type-to-filter)
#include "shell/instrument/instrument_bake.h" // the deferred bake the sync tick runs
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
@@ -39,6 +40,10 @@ using ui::ThumbnailKey;
using ui::thumbnailKeyString;
using util::readFileBytes;
// Sync ticks a bake outcome stays in the title band. Long enough to read, short enough
// that it does not shadow the identity readout.
constexpr int kBakeMessageTicks = 20;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// The default IS the enforced floor (checkSizeConstraint) — the face opens at the size its
@@ -104,6 +109,28 @@ void ReaSamplerEditor::onSyncTimer() {
if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// Resolve the bake affordance's availability on the SAME tick that paints it, so it
// can never be enabled on one tick and refuse on the next.
const bool available = bakeAvailable(processor_->bridge());
if (available != bakeAvailable_) {
bakeAvailable_ = available;
invalidate();
}
// The click armed it; this is where it runs — same thread, one tick later, no mouse
// capture held, and no REAPER action nested inside a mouse handler.
if (bakePending_) {
bakePending_ = false;
const BakeChainResult result = runBake(*processor_);
bakeMessage_ = result.message;
bakeMessageTicks_ = kBakeMessageTicks;
// A landed bake re-pointed the instance and reset the parameter set; re-snapshot
// so the face draws the new capture and its neutral controls.
if (result.ok) refreshFromBank();
invalidate();
}
if (bakeMessageTicks_ > 0 && --bakeMessageTicks_ == 0) invalidate();
// An open editor is the focused assignment target (thundering-herd policy); instances
// with no editor open never poll (the timer is bound to the child window).
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
+204
View File
@@ -0,0 +1,204 @@
// See instrument_bake.h.
#include "shell/instrument/instrument_bake.h"
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <vector>
#include "core/capture/wav_codec.h" // buildFloat32Wav (the bank byte format)
#include "core/instrument/bake/bake_plan.h"
#include "core/instrument/bake/bake_render.h"
#include "core/instrument/bake/bake_reset.h"
#include "core/instrument/note/tempo.h"
#include "core/wire/bake_wire.h"
#include "ext_keys.h"
#include "shell/instrument/reaper_bridge.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using capture::buildFloat32Wav;
using instrument::bake::defaultBakeProgram;
using instrument::bake::planBake;
using instrument::bake::renderBake;
using instrument::bake::resetAfterBake;
using instrument::map::SampleRefEntry;
using instrument::map::SelectedSample;
using instrument::note::Tempo;
using instrument::note::resolveNote;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
namespace {
namespace fs = std::filesystem;
// Deletes the staged file on EVERY exit path, success or failure — the same stack-RAII
// discipline the capture shell's FX-bypass guard follows. On success the extension has
// already COPIED the bytes into the bank, so the delete here is what keeps the temp from
// outliving the click. Best-effort: a file already gone is not an error.
//
// Orphan policy: a crash between the write and the invoke leaves one file in the OS temp
// directory, which is exactly what that directory is swept for. Nothing here scans or
// deletes files it did not itself create.
class StagedFileGuard {
public:
explicit StagedFileGuard(std::string path) : path_(std::move(path)) {}
~StagedFileGuard() {
if (path_.empty()) return;
std::error_code ec;
fs::remove(path_, ec);
}
StagedFileGuard(const StagedFileGuard&) = delete;
StagedFileGuard& operator=(const StagedFileGuard&) = delete;
private:
std::string path_;
};
// Clears the request key on every exit path. A key left holding a request would be picked
// up by the next bake's landing pass and re-run against a temp file that no longer exists.
class RequestKeyGuard {
public:
RequestKeyGuard(ReaperBridge& bridge, std::string key)
: bridge_(bridge), key_(std::move(key)) {}
~RequestKeyGuard() { bridge_.writeBakeExtState(key_, ""); }
RequestKeyGuard(const RequestKeyGuard&) = delete;
RequestKeyGuard& operator=(const RequestKeyGuard&) = delete;
private:
ReaperBridge& bridge_;
std::string key_;
};
BakeChainResult fail(std::string message) {
return BakeChainResult{false, std::move(message)};
}
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;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
} // namespace
bool bakeAvailable(ReaperBridge& bridge) {
return bridge.isConnected() &&
bridge.extensionActionAvailable(wire::bakeActionLookupName());
}
BakeChainResult runBake(ReaSamplerProcessor& processor) {
ReaperBridge& bridge = processor.bridge();
if (!bakeAvailable(bridge))
return fail("resample needs the ReaSampler extension loaded");
const std::string selectionId = processor.selectedSampleId();
if (selectionId.empty()) return fail("nothing loaded to resample");
// The whole ref entry, not just its SelectedSample: the display name that the new
// capture's is derived from sits beside the intrinsics.
const instrument::map::SampleRefs refs = processor.sampleRefs();
const SampleRefEntry* sourceEntry = nullptr;
for (const SampleRefEntry& e : refs)
if (e.sampleId == selectionId) { sourceEntry = &e; break; }
if (!sourceEntry) return fail("the loaded capture has no resolvable file");
const SelectedSample* source = &sourceEntry->ref;
const int sampleRate = static_cast<int>(processor.sampleRate());
if (sampleRate <= 0) return fail("the host has not reported a sample rate yet");
const std::optional<Tempo> tempo = Tempo::fromBpm(bridge.projectTempoBpm());
if (!tempo) return fail("the project tempo could not be read");
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");
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);
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
// own 32-bit-float contract, so the round trip is exact.
std::vector<double> interleaved(audio.interleaved.begin(), audio.interleaved.end());
const std::vector<std::uint8_t> bytes =
buildFloat32Wav(audio.channelCount, static_cast<std::uint32_t>(audio.sampleRate),
static_cast<std::size_t>(audio.frameCount()), interleaved);
const std::string instanceGuid = processor.usageInstanceGuid();
const std::int64_t stamp = static_cast<std::int64_t>(std::time(nullptr));
// OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray
// file there would read as a prune orphan.
std::error_code ec;
const fs::path stagedPath =
fs::temp_directory_path(ec) /
("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav");
if (ec) return fail("no writable temp directory for the staged render");
const std::string staged = stagedPath.string();
StagedFileGuard stagedGuard(staged);
if (!writeFileBytes(staged, bytes)) return fail("could not stage the rendered file");
BakeRequest request;
request.instanceGuid = instanceGuid;
request.stagedFilePath = staged;
request.sourceSampleId = selectionId;
request.sourceRelativePath = source->relativePath;
request.sourceDisplayName = sourceEntry->displayName;
request.ownUsageKey = usageKeyFor(instanceGuid);
request.rootNote = plan->note;
request.generation = stamp;
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request");
// Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning.
if (!bridge.invokeExtensionAction(wire::bakeActionLookupName()))
return fail("the ReaSampler extension's bake action is not registered");
const std::optional<std::string> raw = bridge.readReasamplerExtState(key);
const std::optional<BakeOutcome> outcome =
raw ? wire::decodeBakeOutcome(*raw) : std::nullopt;
// No outcome at all means the action never reached our request — an older extension
// that registers the id but does not read this key, or an invocation REAPER deferred.
if (!outcome) return fail("the extension did not answer the bake request");
if (outcome->generation != request.generation)
return fail("the extension answered a different bake request");
if (outcome->status != BakeStatus::Ok)
return fail(outcome->message.empty() ? std::string("the bake was refused")
: outcome->message);
SampleRefEntry entry;
entry.sampleId = outcome->sampleId;
entry.displayName = outcome->displayName;
entry.ref.relativePath = outcome->relativePath;
entry.ref.rootNote = outcome->rootNote;
entry.ref.channelCount = outcome->channelCount;
// No loop: the loop points shaped the render and are meaningless against the new file
// (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer).
const instrument::bake::BakeReset reset = resetAfterBake(dialed);
processor.adoptBakedCapture(entry, reset.params, reset.masterGainLinear);
// The extension's own wording, which distinguishes the three landings (replaced, added,
// and pointed at an identical existing entry) more precisely than this side can.
return BakeChainResult{true, "resampled -- " + outcome->message};
}
} // namespace reasampler::vst
+36
View File
@@ -0,0 +1,36 @@
// instrument_bake — the instrument's half of the resample chain: render the dialed sound
// offline, stage it outside the bank, ask the extension to bank it, then re-point and go
// neutral. UI thread only; nothing here is on any audio path.
//
// The extension is REQUIRED. Without it the bank has no writer, so the affordance reads
// Disabled and this refuses rather than half-baking.
#pragma once
#include <string>
namespace reasampler::vst {
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.
bool bakeAvailable(ReaperBridge& bridge);
struct BakeChainResult {
bool ok = false;
std::string message; // always populated — the editor prints it either way
};
// Runs the whole chain against `processor`. On failure NOTHING has changed: no temp file
// survives, no bank entry was added, no assign was written, and the dialed state is
// exactly as it was before the click.
//
// MUST NOT be called from a mouse handler. It invokes a REAPER action synchronously — an
// offline landing nested inside a click, with the mouse captured, re-pointing the very
// instance whose frame is on the stack. The editor defers it to its own timer tick.
BakeChainResult runBake(ReaSamplerProcessor& processor);
} // namespace reasampler::vst
+70 -18
View File
@@ -77,6 +77,18 @@ std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
return out;
}
// The ONE decode + build the reload and the bake snapshot share, so a baked render can
// never be built from a different fold than the one the user is hearing.
std::optional<SampleData> buildFromRef(const SelectedSample& sel,
const InstrumentParams& params,
const std::string& projectDir, ChannelMode mode) {
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel.relativePath, mode);
if (!pcm) return std::nullopt;
SampleData sample = buildSampleData(resolveCapture(sel, params), std::move(*pcm));
if (!sample.playable()) return std::nullopt;
return sample;
}
} // namespace
std::string ReaSamplerProcessor::reloadInstrument() {
@@ -144,25 +156,23 @@ std::string ReaSamplerProcessor::reloadInstrument() {
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
havePlayable = sample.playable();
if (havePlayable) {
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
if (std::optional<SampleData> decoded =
buildFromRef(*sel, params, projectDir, mode)) {
sample = std::move(*decoded);
havePlayable = true;
// Point the built snapshot at the instance's ONE live block and seed it from
// the very PlayParams the voices latch, so an untouched knob folds to the same
// frames the build resolved and a note-on with a live block sounds identical
// to one without.
sample.live = &liveParams_;
{
// reloadMutex_ (held for this whole function) nests livePublishMutex_ here;
// publishLiveParams never holds reloadMutex_, so this is the only nesting.
std::lock_guard<std::mutex> lp(livePublishMutex_);
liveParams_.publish(instrument::engine::foldLive(sample.play));
}
builtSampleRate_.store(sample.sampleRate, std::memory_order_relaxed);
resolvedId = selId; // the concrete pick that resolved
}
}
@@ -190,6 +200,48 @@ std::string ReaSamplerProcessor::reloadInstrument() {
return resolvedId;
}
std::string ReaSamplerProcessor::usageInstanceGuid() {
std::lock_guard<std::mutex> lock(usageMutex_);
if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid();
return instanceGuid_;
}
std::optional<SampleData> ReaSamplerProcessor::bakeSnapshot() {
const std::string selId = selectedSampleId();
if (selId.empty()) return std::nullopt;
const InstrumentParams params = instrumentParams();
SampleRefs refs;
{
std::lock_guard<std::mutex> rl(refsMutex_);
refs = sampleRefs_;
}
const SelectedSample* sel = findRef(refs, selId);
if (!sel) return std::nullopt;
// No live block is attached: the bake renders the stored parameter set, which is what
// every knob has already written, rather than whatever the audio thread is observing.
return buildFromRef(*sel, params, bridge_.activeProjectDir(), channelMode());
}
void ReaSamplerProcessor::adoptBakedCapture(const SampleRefEntry& entry,
const InstrumentParams& reset,
double masterGainLinear) {
{
std::lock_guard<std::mutex> rl(refsMutex_);
bool replaced = false;
for (SampleRefEntry& existing : sampleRefs_) {
if (existing.sampleId == entry.sampleId) { existing = entry; replaced = true; break; }
}
if (!replaced) sampleRefs_.push_back(entry);
}
setSelectedSampleId(entry.sampleId);
setInstrumentParams(reset);
setMasterGainLinear(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.
reloadInstrument();
}
void ReaSamplerProcessor::publishUsage(const SampleRefs& refs,
const std::vector<std::string>& ids) {
if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do
+71 -11
View File
@@ -42,6 +42,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
setProjExtState_ = nullptr;
getTrackGuid_ = nullptr;
guidToString_ = nullptr;
namedCommandLookup_ = nullptr;
mainOnCommandEx_ = nullptr;
getCursorPositionEx_ = nullptr;
timeMapGetTimeSigAtTime_ = nullptr;
hostApp_ = nullptr;
if (!context) return false;
@@ -69,6 +73,17 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
reaper->getReaperApi("GetTrackGUID"));
guidToString_ = reinterpret_cast<GuidToStringFn>(
reaper->getReaperApi("guidToString"));
// The bake crossing: resolve the extension's action id by name, then fire it against
// this instance's own project. All degrade to null gracefully — an unresolvable pair
// simply leaves the bake affordance disabled.
namedCommandLookup_ = reinterpret_cast<NamedCommandLookupFn>(
reaper->getReaperApi("NamedCommandLookup"));
mainOnCommandEx_ = reinterpret_cast<MainOnCommandExFn>(
reaper->getReaperApi("Main_OnCommandEx"));
getCursorPositionEx_ = reinterpret_cast<GetCursorPositionExFn>(
reaper->getReaperApi("GetCursorPositionEx"));
timeMapGetTimeSigAtTime_ = reinterpret_cast<TimeMapGetTimeSigAtTimeFn>(
reaper->getReaperApi("TimeMap_GetTimeSigAtTime"));
return getProjExtState_ != nullptr;
}
@@ -95,25 +110,70 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
return decodeGetProjExtState(read.apiReturn, read.value);
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix) {
if (!setProjExtState_ || !hostApp_) return false;
// Read-only-bank guard: this module writes usage keys and nothing else. A non-
// "rsusage_" key is refused rather than widening the instrument's write surface
// (banks/view/tail/assign stay extension-owned).
const std::string prefix = kProjExtUsageKeyPrefix;
if (usageKey.compare(0, prefix.size(), prefix) != 0) return false;
// Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes
// and nothing else. Any other key is refused rather than widening the instrument's
// write surface (banks/view/tail/assign stay extension-owned).
const std::string prefix = requiredPrefix;
if (key.compare(0, prefix.size(), prefix) != 0) return false;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns the size of the extname's state — after storing a
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
// publish path retries next reload tick; a silent drop would leave holds unprotected).
// A deliberate CLEAR (empty value) shrinks the state and can legitimately return 0,
// so it is reported as landed.
const int rv =
setProjExtState_(proj, kProjExtNamespace(), usageKey.c_str(), value.c_str());
// Deliberately NO MarkProjectDirty: a usage change always rides a component-state
// change that already dirties the project.
return rv > 0;
setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return value.empty() ? true : rv > 0;
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
const std::string& value) {
return writeGuarded(usageKey, value, kProjExtUsageKeyPrefix);
}
bool ReaperBridge::writeBakeExtState(const std::string& bakeKey,
const std::string& value) {
return writeGuarded(bakeKey, value, kProjExtBakeKeyPrefix);
}
int ReaperBridge::lookupCommand(const std::string& commandName) {
if (!namedCommandLookup_ || commandName.empty()) return 0;
return namedCommandLookup_(commandName.c_str());
}
bool ReaperBridge::extensionActionAvailable(const std::string& commandName) {
return mainOnCommandEx_ != nullptr && lookupCommand(commandName) != 0;
}
bool ReaperBridge::invokeExtensionAction(const std::string& commandName) {
if (!mainOnCommandEx_ || !hostApp_) return false;
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.
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.
mainOnCommandEx_(command, 0, proj);
return true;
}
double ReaperBridge::projectTempoBpm() {
if (!timeMapGetTimeSigAtTime_ || !getCursorPositionEx_ || !hostApp_) return 0.0;
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3);
int num = 0, denom = 0;
double tempo = 0.0;
timeMapGetTimeSigAtTime_(proj, getCursorPositionEx_(proj), &num, &denom, &tempo);
// The meter is read but not applied: the note model's beat is a quarter note by
// ruling (core/instrument/note/CLAUDE.md), so the undivided BPM is the right one.
return tempo;
}
std::string ReaperBridge::currentTrackGuid() {
+56 -8
View File
@@ -44,15 +44,45 @@ public:
// project or when unconnected. Not RT-safe.
std::string activeProjectDir();
// Writes THIS INSTANCE's usage record: the ONE sanctioned instrument-side ext-state
// write. `usageKey` MUST carry the "rsusage_" prefix (ext_keys.h's usageKeyFor); any
// other key is refused, enforcing the read-only-bank invariant structurally (banks/
// view/tail/assign stay unwritable from the instrument). Returns true iff written
// (the SetProjExtState return is checked). NOT RT-safe — publish sites are the
// off-audio-thread reload path only. Deliberately does NOT mark the project dirty: a
// usage change always rides a component-state change that already does.
// The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// key prefix and refusing every other key. That structural refusal is what keeps the
// read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here —
// and neither payload is bank state. Both return true iff the write landed (the
// SetProjExtState return is checked) and neither is RT-safe: the call sites are the
// off-audio-thread reload path and the editor's UI tick.
//
// Neither marks the project dirty. A usage change always rides a component-state change
// that already does; a bake request is transient and is cleared in the same tick.
// THIS INSTANCE's usage record. `usageKey` MUST carry the "rsusage_" prefix
// (ext_keys.h's usageKeyFor).
bool writeUsageExtState(const std::string& usageKey, const std::string& value);
// THIS INSTANCE's resample-bake request. `bakeKey` MUST carry the "rsbake_" prefix
// (ext_keys.h's bakeKeyFor). An empty value clears the key.
bool writeBakeExtState(const std::string& bakeKey, const std::string& value);
// --- Extension action invocation (the bake crossing) ------------------------------
//
// `commandName` is the NamedCommandLookup spelling — the registered command_id string
// with a leading underscore, which the registration string itself does not carry.
// Whether the extension is loaded AND has registered this action. Used to paint the
// 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.
bool invokeExtensionAction(const std::string& commandName);
// The effective project tempo (BPM, quarter notes per minute) at the edit cursor of
// this instance's own project. 0.0 when unconnected or unresolvable — the caller
// refuses rather than substituting a tempo (no hardcoded rates or tempos in src/).
double projectTempoBpm();
// The canonical GUID string of the track hosting this FX instance (same rendering as
// the extension's track_guid::guidString, so usage records compare byte-equal
// against its live-FX enumeration). Empty when unconnected or no track context (the
@@ -70,9 +100,14 @@ private:
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path. idx=-1 (current
// tab) follows the active project, same convention as the persist shell.
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
// Used ONLY by writeUsageExtState (prefix-guarded) — see the read-only-bank note there.
// Used ONLY by the two prefix-guarded writers — see the read-only-bank note there.
using SetProjExtStateFn = int (*)(void* proj, const char* extname, const char* key,
const char* value);
using NamedCommandLookupFn = int (*)(const char* commandName);
using MainOnCommandExFn = void (*)(int command, int flag, void* proj);
using GetCursorPositionExFn = double (*)(void* proj);
using TimeMapGetTimeSigAtTimeFn = void (*)(void* proj, double time, int* numOut,
int* denomOut, double* tempoOut);
// Opaque-pointer signatures so the header stays SDK-type-free; the GUID* is passed
// straight through, never dereferenced here.
using GetTrackGuidFn = void* (*)(void* tr);
@@ -85,6 +120,19 @@ private:
SetProjExtStateFn setProjExtState_ = nullptr;
GetTrackGuidFn getTrackGuid_ = nullptr;
GuidToStringFn guidToString_ = nullptr;
NamedCommandLookupFn namedCommandLookup_ = nullptr;
MainOnCommandExFn mainOnCommandEx_ = nullptr;
GetCursorPositionExFn getCursorPositionEx_ = nullptr;
TimeMapGetTimeSigAtTimeFn timeMapGetTimeSigAtTime_ = nullptr;
// The one prefix guard both public writers route through, so the two cannot diverge
// in how strictly they refuse a key.
bool writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix);
// 0 when the action is not registered (the extension is absent or older). REAPER's
// documented "not found" return is 0 by convention only — the header does not state
// it — so every caller treats 0 as unavailable and never as a valid command id.
int lookupCommand(const std::string& commandName);
};
} // namespace reasampler::vst
+11
View File
@@ -121,6 +121,7 @@ private:
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kPreview, // the preview-trigger button
kBake, // the resample-bake trigger
kControl, // a knob-deck element (index = control id)
kInnerDial, // a knob cell's inner curve dial (index = the OUTER control id)
kEnvRadio, // an envelope deck's overlay-select radio (index = radio control id)
@@ -422,6 +423,16 @@ private:
// up. One note at a time — a fresh press releases the prior.
int previewingNote_ = -1;
// Resample bake. The click only ARMS it; the sync tick runs it. Running it inline
// would nest a synchronous REAPER action — which re-points this very instance — inside
// a mouse handler with the capture held.
bool bakePending_ = false;
// Whether the extension's bake action is registered, resolved on the same tick that
// governs the button's paint, so the control is never enabled and then refusing.
bool bakeAvailable_ = false;
std::string bakeMessage_; // last outcome, shown in the title band
int bakeMessageTicks_ = 0; // sync ticks the message survives
// The editor-drop -> extension-ingest relay is not shipped (the bridge is read-only):
// an OS drop just flashes a "drop on the panel instead" banner (dropHintTicks_ counts
// down via the sync tick). Never ingests, never inserts a timeline item.
@@ -26,6 +26,7 @@ namespace reasampler::vst {
using instrument::map::ComponentState;
using instrument::map::InstrumentParams;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
@@ -120,6 +121,21 @@ public:
// resolved selection id ("" if nothing loaded).
std::string reloadInstrument();
// The dialed sound as plain data for the offline bake: the SAME refs resolve + decode +
// build reloadInstrument runs, with no live block attached. Rebuilt rather than copied
// off the live snapshot because a tier-3 live edit leaves that snapshot's own play
// params stale on purpose — copying it would bake the pre-drag values. nullopt when
// nothing is loaded or the WAV is unreadable. Off the audio thread (file I/O).
std::optional<SampleData> bakeSnapshot();
// 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.
void adoptBakedCapture(const SampleRefEntry& entry, const InstrumentParams& reset,
double masterGainLinear);
// What pollBankSync did this tick, so the editor can react only when something changed.
struct BankSyncResult {
bool reloaded = false; // bank generation changed (or a legacy lift landed) -> reloaded
@@ -203,6 +219,11 @@ public:
// fallback when the bank blob is unreadable. Guarded by refsMutex_.
SampleRefs sampleRefs();
// This instance's usage identity, minted here if it has never published. It names BOTH
// the "rsusage_" record the bake's tie query must exclude and the "rsbake_" request
// key, so the two can never name different instances. Off the audio thread.
std::string usageInstanceGuid();
private:
// If process() published that the drain instrument is fully idle, move it into the
// graveyard and prune — so an edited-away snapshot stops costing memory as soon as its
+1 -1
View File
@@ -51,7 +51,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
+13
View File
@@ -204,6 +204,19 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
} // namespace
tracking::Answer ReaSamplerSession::tiedUsageFor(const std::string& capturePath,
const std::string& ownUsageKey) const {
// The SECOND consumer of the same gather the prune scan above runs — deliberately
// next to it, so "both answers come out of one TrackingState" is structural rather
// than a rule two files have to remember.
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return tracking::Answer::Indeterminate;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{trackingStatus_, tracking_, usage};
return tracking::tiedUsageExists(state, capturePath, ownUsageKey);
}
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report =
+8
View File
@@ -28,6 +28,7 @@
#include "core/model/bank_model.h"
#include "core/reclaim/prune_reconcile.h"
#include "core/tracking/origin_ledger.h"
#include "core/tracking/tracking_authority.h"
#include "core/version/app_version.h"
#include "core/view/view_mode_model.h"
@@ -115,6 +116,13 @@ public:
// prune action confirms this set before deleting it. Read-only.
std::vector<std::string> pruneOrphanSet() const;
// The resample's replace-vs-add input for one capture, gathered from the SAME live
// tracking state the prune scan reads, so the two consumers cannot disagree. Exposed
// as the answer rather than as the ledger, because an absent record and an unreadable
// ledger demand opposite treatment and only the pair says which. Read-only.
tracking::Answer tiedUsageFor(const std::string& capturePath,
const std::string& ownUsageKey) const;
// Delete the confirmed orphan set — the sole file-deletion path,
// callable only after an explicit user confirm. Re-enumerates and runs
// the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a