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

This commit is contained in:
2026-08-01 17:05:28 -04:00
parent 60308a3655
commit 39c2d1cdb4
31 changed files with 679 additions and 201 deletions
+27 -12
View File
@@ -18,26 +18,35 @@ decision about what the render made obsolete.
can neither be observed nor disturbed by a bake, and a repeated bake of one dialed sound
is byte-identical because nothing outside the passed value can vary between runs.
- **The window bounds the render; the envelope does not.** Termination is structural — the
loop runs to `BakePlan::totalFrames` and stops. That is why a Gate bake with a sustain
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
even a pathological envelope cannot run past the window.
- **The block size is fixed here, not taken from the host.** A block boundary is where the
engine re-observes state, so pinning it is part of what makes two bakes on two hosts
produce the same bytes.
- **A degenerate window is refused, not rendered.** `planBake` returns nullopt for a
collapsed window, a non-positive rate, or a window that rounds to no frames.
- **The whole signal chain is printed, master gain included.** `renderBake` scales its
output by the dialed post-mixer gain, because `resetAfterBake` hands that control back at
unity. A render that summed voices alone would return every iteration shifted by 1/gain,
and a gain dialed to silence would come back at full level.
- **A degenerate or unholdable window is refused, not rendered.** `planBake` returns nullopt
for a collapsed window, a non-positive rate, a window that rounds to no frames, and one
past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
seconds→frames narrowing is undefined long before the allocation would fail.
- **The reset's survive list is written out; everything else defaults.** `resetAfterBake`
starts from a default-constructed parameter set and copies back only the mapping facts.
A parameter added later therefore resets by default — the safe direction, since
under-resetting applies the same processing twice while over-resetting costs a re-dial.
A new mapping fact must be added to the copy list explicitly.
- **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one
classification this track made against the ratified rule rather than reading off it. The
bake's product is a finished one-shot, and Trigger is the mode that plays a finished
one-shot verbatim; Gate would re-gate the printed release tail and each iteration would
truncate the previous one's. "Neutral" here means "adds no processing", not "the struct's
own default". `bake_reset.cpp` carries the argument at the assignment.
## Modules
- `bake_plan``defaultBakeProgram` (the program a bake uses until the capture-signal
popup ships; its release tail exists so the bake is not truncated at note-off),
`BakePlan` (the frame window plus its two event frames), and `planBake`, the one
`ResolvedNote` + rate -> frames resolution.
- `bake_plan``defaultBakeProgram` (the program a bake uses until the capture-signal popup
ships; its end offset is DERIVED from the dialed sound, never constant), `BakePlan` (the
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`planBake`, the one `ResolvedNote` + rate -> frames resolution.
- `bake_render``BakeAudio` and `renderBake`: the programmed note through the sample's
own voice path, summed into an interleaved buffer at the source's own channel count.
- `bake_reset``BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
@@ -45,7 +54,13 @@ decision about what the render made obsolete.
## Gotchas
- Frame 0 of the render is the start of the CAPTURED FILE, not note-on. A capture that
opens before the note has `noteOnFrame > 0` and silence ahead of it.
- **`BakePlan` speaks two frame domains** — the captured file's and the render's, which are
offset from each other whenever the note and the capture window do not start together.
`bake_plan.h` says which field is in which; do not read them as one clock.
- **`defaultBakeProgram`'s Trigger window bounds the Varispeed read stretch, it does not
model it.** A downward pitch offset makes the read head take longer to cross the play
span, so the window is scaled by the deepest downward offset the voice can reach — an
upper bound, so a shallower excursion leaves trailing silence in the file. The
capture-signal popup is where a user sets the window exactly.
- The render's channel count is the loaded `SampleData`'s, which is already the instance's
channel-mode decision — a mono-mode instance bakes mono, and that is faithful, not a fold.
+5 -3
View File
@@ -1,6 +1,8 @@
# Links only note_program: a plan is the programmed note resolved against a rate, and
# nothing about the engine or the bank is needed to compute one.
reasampler_pure_library(bake_plan SOURCES bake_plan.cpp LINK PUBLIC note_program)
# The default program's window is derived from the DIALED sound, so the plan reads the
# engine's value layer (sampler_core) and the one Trigger span formula (trigger_seam).
reasampler_pure_library(bake_plan
SOURCES bake_plan.cpp
LINK PUBLIC note_program sampler_core trigger_seam)
reasampler_test(bake_plan LINK bake_plan)
reasampler_pure_library(bake_render
+71 -17
View File
@@ -5,6 +5,8 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
namespace reasampler::instrument::bake {
using note::NoteProgram;
@@ -12,17 +14,62 @@ using note::ResolvedNote;
namespace {
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here
// uses, so the window and its two event frames cannot round against each other.
std::int64_t toFrames(double seconds, int rate) {
return static_cast<std::int64_t>(std::llround(seconds * static_cast<double>(rate)));
// Seconds -> frames by round-half-away-from-zero, the one conversion every field here uses,
// so the window and its event frames cannot round against each other. Reports failure
// rather than clamping: the double->int64 narrowing below is undefined once the product
// leaves int64's range, which a legal offset magnitude reaches long before that.
bool toFrames(double seconds, int rate, std::int64_t& out) {
const double frames = seconds * static_cast<double>(rate);
const auto ceiling = static_cast<double>(kMaxBakeFrames);
if (!(frames >= -ceiling && frames <= ceiling)) return false; // also catches NaN
out = static_cast<std::int64_t>(std::llround(frames));
return true;
}
// The deepest DOWNWARD pitch offset the dialed voice can reach, in semitones (<= 0). Only
// Varispeed needs it: there the read head advances at the pitch ratio, so a downward offset
// stretches how long Trigger's source span takes to play. Preserve decouples the two, and a
// Gate release is ticked per output frame, so neither is affected.
double downwardSemitones(const PlayParams& play, int velocity) {
if (play.pitchEngine != PitchEngine::Varispeed) return 0.0;
double down = (std::min)(0.0, kVelocityPitchRangeSemitones *
play.pitchVelocityCurve.eval(velocity));
if (play.pitchEnv.enabled) {
// A drawn contour is bipolar, so it reaches -|peak| whichever way the depth points;
// the staged AHD only ever travels between 0 and the peak.
down += play.pitchSpline.mode == EnvMode::Spline
? -std::fabs(play.pitchEnv.peakSemitones)
: (std::min)(0.0, play.pitchEnv.peakSemitones);
}
return down;
}
} // namespace
NoteProgram defaultBakeProgram() {
NoteProgram p;
p.end = note::EndOffset(note::offsetFromMs(kDefaultReleaseTailMs));
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo) {
NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on
if (renderSampleRate <= 0) return p;
const double rate = static_cast<double>(renderSampleRate);
double endOffsetSeconds = 0.0;
if (dialed.play.playMode == PlayMode::Trigger) {
// Trigger ignores note-off entirely: the sound ends when the read head reaches the
// play span's end, which has nothing to do with the note's length — so the end
// offset is whatever is left after the note, positive or negative.
const std::int64_t span = map::triggerPlayLength(
dialed.play.trigger.lengthFraction,
static_cast<std::int64_t>(dialed.frames.size()), dialed.startFrame);
const double stretch = std::pow(
2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
endOffsetSeconds = static_cast<double>(span) / rate * stretch -
tempo.beatsToSeconds(note::divisionBeats(p.length));
} else {
// Gate: the release is the one stage that runs after note-off, so it is exactly
// what the window has to hold past it.
endOffsetSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
}
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
return p;
}
@@ -31,20 +78,27 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
if (resolved.windowCollapsed) return std::nullopt;
if (sampleRate <= 0) return std::nullopt;
// The render starts at whichever comes first, note-on or the capture opening. A POSITIVE
// start offset is legal and means the capture opens after the note — so the head is
// rendered and discarded, never folded away by sliding note-on later inside the window.
const double renderStartSeconds = (std::min)(resolved.captureStartSeconds, 0.0);
BakePlan plan;
plan.sampleRate = sampleRate;
plan.totalFrames = toFrames(resolved.captureLengthSeconds(), sampleRate);
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
return std::nullopt;
if (plan.totalFrames <= 0) return std::nullopt;
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
plan.leadInFrames))
return std::nullopt;
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt;
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
plan.noteOffFrame))
return std::nullopt;
// Each field cleared the ceiling alone; the render holds their sum.
if (plan.renderFrames() > kMaxBakeFrames) return std::nullopt;
// Note-on sits at -captureStart into the window: a negative start offset (the capture
// opens early) pushes it later, a positive one has already been clamped away by
// resolveNote's own window.
plan.noteOnFrame = std::clamp(toFrames(-resolved.captureStartSeconds, sampleRate),
std::int64_t{0}, plan.totalFrames);
plan.noteOffFrame =
std::clamp(toFrames(resolved.noteOffSeconds - resolved.captureStartSeconds,
sampleRate),
plan.noteOnFrame, plan.totalFrames);
plan.noteOffFrame = (std::max)(plan.noteOffFrame, plan.noteOnFrame);
plan.note = std::clamp(rootNote, 0, 127);
plan.velocity = std::clamp(static_cast<int>(resolved.velocity), 1, 127);
return plan;
+31 -18
View File
@@ -1,5 +1,5 @@
// bake_plan — the programmed note resolved against a concrete sample rate: the frame
// window the offline pass renders, and the two event frames inside it.
// bake_plan — the programmed note resolved against a concrete sample rate: the frames the
// offline pass renders, the slice of them the capture keeps, and the two event frames.
//
// Separate from bake_render because the plan is what a preview and a bake must agree on;
// the render is only one consumer of it.
@@ -9,36 +9,49 @@
#include <cstdint>
#include <optional>
#include "core/instrument/engine/play_params.h" // SampleData (the dialed sound)
#include "core/instrument/note/note_program.h"
namespace reasampler::instrument::bake {
// Until the capture-signal popup ships, the bake needs a program to render. A zero end
// offset ends the capture exactly at note-off, which truncates every release — so the
// default opens the window past the gate by this much.
inline constexpr double kDefaultReleaseTailMs = 500.0;
// The render's frame ceiling, refused like any other degenerate window. A legal offset
// magnitude reaches ~11.6 days, and renderBake allocates two channel buffers plus an
// interleaved one from the window — an unbounded one is a bad_alloc inside a UI tick, not a
// long bake. ~5.5 minutes at 48 kHz, past any musical programmed note.
inline constexpr std::int64_t kMaxBakeFrames = 16'000'000;
// The program a bake uses when nothing has been entered: one quarter note at the default
// velocity, opening at note-on and closing kDefaultReleaseTailMs after the release starts.
note::NoteProgram defaultBakeProgram();
// The program a bake uses until the capture-signal popup ships: one quarter note at the
// default velocity, opening at note-on, with the END offset derived from `dialed` — Gate's
// release, or Trigger's play span, at `renderSampleRate` (the rate the bake will render at,
// which is what the engine's frame counts are actually consumed against). Derived rather
// than constant because the release knob alone spans two seconds, so any fixed tail cuts a
// long decay mid-flight.
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo);
// The render window in frames. Frame 0 is the start of the captured file, NOT note-on:
// a negative start offset opens the capture before the note, and noteOnFrame is where the
// note actually lands inside it.
// The render window in frames. TWO domains meet here: `totalFrames` is the captured FILE's
// length, everything else counts RENDER frames from whichever comes first, note-on or the
// capture opening. A positive start offset (legal — it trims the attack) puts note-on at
// render frame 0 and the file's frame 0 `leadInFrames` later; a negative one does the
// reverse, and the file opens on silence before the note. Either event frame may sit past
// the render, which then closes before the note ever fires — a legal empty capture.
struct BakePlan {
std::int64_t totalFrames = 0;
std::int64_t noteOnFrame = 0; // in [0, totalFrames]
std::int64_t noteOffFrame = 0; // in [noteOnFrame, totalFrames]
std::int64_t totalFrames = 0; // frames in the captured file
std::int64_t leadInFrames = 0; // rendered ahead of the file's frame 0, then discarded
std::int64_t noteOnFrame = 0; // both in render frames
std::int64_t noteOffFrame = 0;
// The capture's root: rendering AT root is what makes the root survivable, which is
// why the root parameter is the one processing control a bake does not reset.
int note = 60;
int velocity = 100;
int sampleRate = 0;
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
};
// nullopt for a collapsed window, a non-positive rate, or a window that rounds to no
// frames — a degenerate buffer is refused rather than rendered. `rootNote` and the
// resolved velocity are clamped into MIDI range.
// nullopt for a collapsed window, a non-positive rate, a window that rounds to no frames,
// or one past kMaxBakeFrames — a buffer that is degenerate or unholdable is refused rather
// than rendered. `rootNote` and the resolved velocity are clamped into MIDI range.
std::optional<BakePlan> planBake(const note::ResolvedNote& resolved, int sampleRate,
int rootNote);
+22 -15
View File
@@ -11,16 +11,17 @@ namespace reasampler::instrument::bake {
namespace {
// A fixed render block, deliberately independent of the host's: the block boundary is
// where the engine observes live parameters and re-checks voice state, so pinning it here
// is what keeps two bakes of one dialed sound byte-identical on different hosts.
// A fixed render block rather than the host's. A block boundary is where the engine
// re-observes live state, and the detach below leaves it nothing to observe — so this is
// defence in depth against a future block-boundary read, not the reason two bakes agree.
constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
if (plan.leadInFrames < 0 || plan.renderFrames() > kMaxBakeFrames) return out;
// The live block is the audio thread's moving target; a render that observed it would
// depend on what the user happened to be dragging. The dialed values are already in
@@ -28,9 +29,9 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
sample.live = nullptr;
const int channels = sample.channelCount();
const auto total = static_cast<std::size_t>(plan.totalFrames);
std::vector<AudioSample> left(total, 0.f);
std::vector<AudioSample> right(channels == 2 ? total : 0u, 0.f);
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
std::vector<AudioSample> left(rendered, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
@@ -40,17 +41,18 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.totalFrames;) {
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
// Trigger ignores note-off by design; in Gate this is the release the programmed
// note length bounds.
if (pos == plan.noteOffFrame) engine.noteOff(plan.note);
// Stop the block at the next event frame so both land sample-accurately.
std::int64_t limit = plan.totalFrames;
if (pos < plan.noteOnFrame) limit = plan.noteOnFrame;
else if (pos < plan.noteOffFrame) limit = plan.noteOffFrame;
const std::int64_t chunk = std::min(limit - pos, kBlockFrames);
// Stop the block at the next event frame so both land sample-accurately. An event
// past the window (a capture that closes before the note) never bounds anything.
std::int64_t limit = plan.renderFrames();
if (pos < plan.noteOnFrame) limit = (std::min)(limit, plan.noteOnFrame);
else if (pos < plan.noteOffFrame) limit = (std::min)(limit, plan.noteOffFrame);
const std::int64_t chunk = (std::min)(limit - pos, kBlockFrames);
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
const auto at = static_cast<std::size_t>(pos);
@@ -62,10 +64,15 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan) {
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
// A flat multiply, not the processor's per-sample ramp: the gain is constant for the
// whole render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[f];
if (channels == 2) out.interleaved[f * channels + 1] = right[f];
out.interleaved[f * channels] = left[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
}
return out;
}
+7 -8
View File
@@ -1,9 +1,8 @@
// bake_render — the offline pass: one programmed note through a voice engine built for
// this render alone, summed into an interleaved buffer.
//
// Never touches a live engine and never runs on the audio thread: it takes the SampleData
// BY VALUE precisely so it can detach the live-parameter block before rendering (see
// renderBake), which is what makes a repeated bake byte-identical.
// Never touches a live engine and never runs on the audio thread. Takes its SampleData BY
// VALUE for the reason this directory's CLAUDE.md records.
#pragma once
@@ -30,10 +29,10 @@ struct BakeAudio {
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path. The gate is held for the plan's note
// span and released at noteOffFrame — with the window itself bounding the render, a Gate
// sustain loop terminates by construction rather than by trusting the envelope to end.
// An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan);
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine, printed here because the bake's
// reset hands that control back at unity. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
} // namespace reasampler::instrument::bake
+9
View File
@@ -14,6 +14,15 @@ BakeReset resetAfterBake(const map::InstrumentParams& dialed) {
out.params.keyTrack = dialed.keyTrack;
// There is no key-range parameter to carry (core/instrument/CLAUDE.md: no key-range
// concept) — if one is ever added it belongs on this list, not in the defaults.
// Play mode is on neither ratified list, so it is classified here, and the acceptance
// criteria decide it: the bake's product is a finished one-shot carrying its own
// attack, span and release. Trigger plays that back verbatim — note-off ignored, the
// default AHD flat at unity over the whole span. Gate would re-gate it: the default
// release would cut the printed tail at note-off, and every further iteration would cut
// the previous one's again. "Neutral" for this control means "adds no processing",
// which is Trigger, not the value struct's own Gate default.
out.params.play.playMode = PlayMode::Trigger;
return out;
}
+5 -7
View File
@@ -11,17 +11,15 @@
namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set, but it is post-mixer gain that the render printed, so it belongs to the
// same decision and is answered here rather than left to the shell.
// parameter set, but renderBake prints it into the file, so it belongs to the same decision
// and is answered here rather than left to the shell.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — the dialed gain is in the audio now
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
};
// Everything defaults; the survivors are copied back explicitly. That direction is
// deliberate: a parameter added later is presumptively part of what the render printed,
// and over-resetting a control costs a re-dial while under-resetting silently applies the
// same processing twice. A new MAPPING fact must be added to the copies below.
// Everything defaults; the survivors are copied back explicitly (this directory's CLAUDE.md
// owns why that direction, and which classifications are ratified).
BakeReset resetAfterBake(const map::InstrumentParams& dialed);
} // namespace reasampler::instrument::bake