Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs

This commit is contained in:
2026-08-01 20:26:04 -04:00
parent d3894dae6d
commit 19aeb92775
36 changed files with 1040 additions and 274 deletions
+27 -12
View File
@@ -23,10 +23,23 @@ decision about what the render made obsolete.
even a pathological envelope cannot run past the window.
- **The whole signal chain is printed, master gain included** — the gain multiply in
`bake_render.cpp` carries the argument for why.
- **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.
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses 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 refusal
carries a `BakeRefusal` naming WHICH: past-the-ceiling is a real sound that will not fit,
which reads to the user as a different sentence than an empty window.
- **The window derives itself, and Hold is the one exception.** Trigger derives from the play
span; Gate over an active sustain loop takes the user's Hold, because a loop sounds for as
long as it is held and no derivation can supply a duration; Gate WITHOUT one derives from
source exhaustion, since the read head frees the voice whether or not the gate is down.
`bakeWindowNeedsHold` is the predicate, and it reads the ENGINE's loop fold rather than the
loop fields, so the control that collects Hold cannot appear for a loop the voice refuses.
- **Trailing silence is free; truncation is not.** Every derivation rounds outward — the Gate
note rounds UP to a programmable length that outlasts the source, the Varispeed bound takes
the deepest reachable offset, and every path is padded by the voice's terminal declick ramp
(`kDeclickFrames`, unconditionally — not branched on the pitch engine that has the ramp
today). Judge any change to this module against that asymmetry.
- **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
@@ -38,10 +51,10 @@ decision about what the render made obsolete.
## Modules
- `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
- `bake_plan` — `defaultBakeProgram` (the whole programmed note, DERIVED from the dialed
sound: its note length as well as its end offset), `bakeWindowNeedsHold`, `BakePlan` (the
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`planBake`, the one `ResolvedNote` + rate -> frames resolution.
`planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`.
- `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
@@ -52,10 +65,12 @@ decision about what the render made obsolete.
- **`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.
- **`defaultBakeProgram`'s Varispeed bound is an upper bound, not a model.** A downward pitch
offset makes the read head take longer to cross its span, so the window is scaled by the
deepest downward offset the voice can reach — a shallower excursion leaves trailing silence
in the file. Both the Trigger span and the Gate exhaustion length take it.
- **The bake fires at the instance's PREVIEW velocity, not a constant.** Three velocity curves
are live, so the velocity is a property of the sound being printed and not a detail of the
render; it also feeds the Varispeed bound above (a velocity→pitch curve moves the window).
- 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.
+2 -1
View File
@@ -1,5 +1,6 @@
# 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).
# engine's value layer, its loop fold and its declick length (all sampler_core), the one
# Trigger span formula (trigger_seam), and the note-length ladder (via note_program).
reasampler_pure_library(bake_plan
SOURCES bake_plan.cpp
LINK PUBLIC note_program sampler_core trigger_seam)
+66 -23
View File
@@ -5,7 +5,9 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength (the one span formula)
#include "core/instrument/engine/loop/loop_span.h" // resolveLoop (the one sustain-loop fold)
#include "core/instrument/engine/voice.h" // kDeclickFrames (the terminal ramp length)
#include "core/instrument/map/trigger_seam.h" // the one Trigger span formula + %-fold
namespace reasampler::instrument::bake {
@@ -28,8 +30,8 @@ bool toFrames(double seconds, int rate, std::int64_t& out) {
// 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.
// stretches how long the source takes to play out. 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 *
@@ -44,13 +46,42 @@ double downwardSemitones(const PlayParams& play, int velocity) {
return down;
}
// Voice::start's own clamp: a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
std::int64_t effectiveStart(const SampleData& dialed) {
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = dialed.startFrame;
return (start < 0 || start >= frameCount) ? 0 : start;
}
} // namespace
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount) {
// resolveLoop already refuses a non-Gate voice, so this is exactly "Gate over a loop the
// read path will honour" — the engine's decision, not a second reading of the fields.
return engine::loop::resolveLoop(loop, crossfadeFrames, frameCount,
mode == PlayMode::Gate)
.active;
}
bool bakeWindowNeedsHold(const SampleData& dialed) {
return bakeWindowNeedsHold(dialed.play.playMode, dialed.loop, dialed.loopCrossfadeFrames,
static_cast<std::int64_t>(dialed.frames.size()));
}
NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo) {
NoteProgram p; // 1/4 straight, velocity 100, capture opening at note-on
note::Tempo tempo, note::Division hold,
note::Velocity velocity) {
NoteProgram p; // 1/4 straight, capture opening at note-on
p.velocity = velocity;
if (renderSampleRate <= 0) return p;
const double rate = static_cast<double>(renderSampleRate);
const auto frameCount = static_cast<std::int64_t>(dialed.frames.size());
const std::int64_t start = effectiveStart(dialed);
const double stretch =
std::pow(2.0, -downwardSemitones(dialed.play, p.velocity.value()) / 12.0);
const double releaseSeconds = static_cast<double>(dialed.play.adsr.releaseFrames) / rate;
double endOffsetSeconds = 0.0;
if (dialed.play.playMode == PlayMode::Trigger) {
@@ -58,25 +89,37 @@ NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
// 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);
map::effectiveLengthFraction(dialed.play), frameCount, start);
endOffsetSeconds = static_cast<double>(span) / rate * stretch -
tempo.beatsToSeconds(note::divisionBeats(p.length));
} else if (bakeWindowNeedsHold(dialed)) {
// The loop cycles for as long as the note is held, so the hold IS the length, and the
// release is the one stage that runs after note-off.
p.length = hold;
endOffsetSeconds = releaseSeconds;
} 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;
// Gate with no loop: the read head runs off the source and frees the voice whether or
// not the gate is still down, so the maximal sound is the whole post-start span held.
// Rounding the note UP to a programmable length that outlasts it costs nothing — the
// voice is already gone by then — while a shorter note releases mid-attack.
const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - start);
const double exhaustSeconds = static_cast<double>(postStart) / rate * stretch;
p.length = note::shortestDivisionAtLeast(tempo.secondsToBeats(exhaustSeconds));
endOffsetSeconds = releaseSeconds;
}
// The voice rings its last output out over kDeclickFrames instead of hard-cutting it, and
// that ramp starts where the derivations above end. Added on every path, not just the
// Preserve one that has the ramp today: trailing silence is free, a hard cut is not.
endOffsetSeconds += static_cast<double>(kDeclickFrames) / rate;
p.end = note::EndOffset(note::offsetFromMs(endOffsetSeconds * 1000.0));
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;
PlannedBake planBake(const ResolvedNote& resolved, int sampleRate, int rootNote) {
const PlannedBake empty{std::nullopt, BakeRefusal::EmptyWindow};
const PlannedBake tooLong{std::nullopt, BakeRefusal::PastFrameCeiling};
if (resolved.windowCollapsed) return empty;
if (sampleRate <= 0) return empty;
// 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
@@ -86,22 +129,22 @@ std::optional<BakePlan> planBake(const ResolvedNote& resolved, int sampleRate,
BakePlan plan;
plan.sampleRate = sampleRate;
if (!toFrames(resolved.captureLengthSeconds(), sampleRate, plan.totalFrames))
return std::nullopt;
if (plan.totalFrames <= 0) return std::nullopt;
return tooLong;
if (plan.totalFrames <= 0) return empty;
if (!toFrames(resolved.captureStartSeconds - renderStartSeconds, sampleRate,
plan.leadInFrames))
return std::nullopt;
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return std::nullopt;
return tooLong;
if (!toFrames(-renderStartSeconds, sampleRate, plan.noteOnFrame)) return tooLong;
if (!toFrames(resolved.noteOffSeconds - renderStartSeconds, sampleRate,
plan.noteOffFrame))
return std::nullopt;
return tooLong;
// Each field cleared the ceiling alone; the render holds their sum.
if (plan.renderFrames() > kMaxBakeFrames) return std::nullopt;
if (plan.renderFrames() > kMaxBakeFrames) return tooLong;
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;
return PlannedBake{plan, BakeRefusal::None};
}
} // namespace reasampler::instrument::bake
+43 -12
View File
@@ -20,14 +20,32 @@ namespace reasampler::instrument::bake {
// 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 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.
// Whether the window needs a user-supplied hold. A Gate voice over an ACTIVE sustain loop
// sounds for as long as it is held, by definition — there is no intrinsic end to derive, and
// this is the ONLY case in which there isn't. Answered by the engine's own loop fold, so the
// control that collects the hold cannot appear for a loop the voice would refuse.
bool bakeWindowNeedsHold(PlayMode mode, const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount);
bool bakeWindowNeedsHold(const SampleData& dialed);
// The bake's programmed note, DERIVED from the dialed sound at `renderSampleRate` (the rate
// the bake renders at, which is what the engine's frame counts are consumed against):
//
// Trigger — note length is nominal (note-off is ignored); the end offset carries the
// whole play span, stretched by the deepest downward Varispeed offset.
// Gate, loop — `hold` is the note length; the end offset is the release.
// Gate, no loop— the read head runs off the source and frees the voice whatever the gate is
// doing, so the note is rounded UP to the shortest programmable length that
// outlasts the source. Holding longer than that sounds identical, which is
// what makes the overshoot trailing silence rather than a different sound.
//
// Every case is padded by the voice's terminal declick ramp (kDeclickFrames): trailing
// silence is free, and closing the window on the frame the ramp starts is a hard cut.
// `hold` is read only in the Gate-with-loop case; `velocity` is the velocity the note fires
// at, and it feeds the Varispeed stretch as well as the render.
note::NoteProgram defaultBakeProgram(const SampleData& dialed, int renderSampleRate,
note::Tempo tempo);
note::Tempo tempo, note::Division hold,
note::Velocity velocity);
// 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
@@ -49,10 +67,23 @@ struct BakePlan {
std::int64_t renderFrames() const { return leadInFrames + totalFrames; }
};
// 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);
// Why a window was refused. The two are different user problems and read as different
// sentences: an empty window is a programming mistake, a window past the ceiling is a legal
// dialed sound that simply cannot be held in one pass.
enum class BakeRefusal : std::uint8_t {
None,
EmptyWindow, // collapsed, a non-positive rate, or a window that rounds to no frames
PastFrameCeiling, // representable but longer than kMaxBakeFrames
};
// The one `ResolvedNote` + rate -> frames resolution. A degenerate or unholdable window is
// refused rather than rendered; `refusal` is None iff `plan` holds one. `rootNote` and the
// resolved velocity are clamped into MIDI range.
struct PlannedBake {
std::optional<BakePlan> plan;
BakeRefusal refusal = BakeRefusal::None;
};
PlannedBake planBake(const note::ResolvedNote& resolved, int sampleRate, int rootNote);
} // namespace reasampler::instrument::bake