Ξ-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
+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