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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user