Ξ-W2-T1: the resample bake chain — instrument renders, extension banks, one click re-points and resets
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user