Bake window: derived note lengths carry exact durations, not ladder rungs — a long take is no longer cut at 384 beats

Hold keeps its picker. Also: one home for the %-fold, duration-ordered Hold travel, and a corrupt tail degrades to absent rather than fabricating one.
This commit is contained in:
2026-08-01 21:10:38 -04:00
parent 19aeb92775
commit 65f6070348
35 changed files with 772 additions and 226 deletions
+56 -17
View File
@@ -2,7 +2,8 @@
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
// exhaustion plus its release, a Trigger play span, and the Varispeed read-stretch bound);
// exhaustion plus its release, a start point, a Trigger play span, and the Varispeed
// read-stretch bound on both branches that take it);
// the frame window and both event frames against hand-computed values; a capture opening
// BEFORE note-on and one opening AFTER it; the refusals and what each one reports — a
// collapsed window, a non-positive rate, a window that rounds to nothing, and one past the
@@ -45,8 +46,10 @@ SampleData dialedSample(std::size_t frames = 96000) {
// The Hold default; read only where the window is underivable, which is nowhere in this file.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
NoteProgram derived(const SampleData& s, Tempo t) {
return defaultBakeProgram(s, kRate, t, oneBar(), Velocity{});
// `t` is unused by the derivation itself — it takes no tempo — but every caller here resolves
// against the same one, so threading it keeps each case's two halves visibly paired.
NoteProgram derived(const SampleData& s, Tempo) {
return defaultBakeProgram(s, kRate, oneBar(), Velocity{});
}
bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; }
@@ -60,8 +63,8 @@ int main() {
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
// The note is rounded up to the shortest length outlasting the source — 2 s exactly,
// one bar at 120 BPM — instead of the constant quarter note that released it early.
// The note is held to exhaustion — 2 s exactly — instead of the constant quarter note
// that released it early.
CHECK(near(r.noteOffSeconds, 2.0));
CHECK(r.captureStartSeconds == 0.0);
CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds));
@@ -72,14 +75,50 @@ int main() {
const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));
// A source that does not land on a rung rounds UP. 2.5 s is 5 beats, and the shortest
// rung at or above that is the 2/1 triplet (8 * 2/3 == 5.33 beats) — NOT the dotted
// half above it, which is why picker order is not length order.
// The length is EXACT, not rounded onto the ladder: 2.5 s is 5 beats, which no rung
// hits — the ladder's nearest never-short answer is the 2/1 triplet at 5.33 beats, and
// taking it would buy a third of a second of silence for nothing. The tempo is not an
// input to a derived length at all, so the same sound derives the same seconds at any.
SampleData odd = dialedSample(static_cast<std::size_t>(kRate * 5 / 2)); // 2.5 s
odd.play.playMode = PlayMode::Gate;
const ResolvedNote up = resolveNote(derived(odd, at(120.0)), at(120.0));
CHECK(up.noteOffSeconds >= 2.5); // the property that matters: never short
CHECK(near(up.noteOffSeconds, at(120.0).beatsToSeconds(8.0 * 2.0 / 3.0)));
CHECK(near(resolveNote(derived(odd, at(120.0)), at(120.0)).noteOffSeconds, 2.5));
CHECK(near(resolveNote(derived(odd, at(97.0)), at(97.0)).noteOffSeconds, 2.5));
}
// --- Gate with no loop, a START POINT set: only the post-start span is held ----------
// effectiveStart's whole reason to exist. A start marker means the head begins there, so
// holding the note for the WHOLE source would pad the window with silence the voice
// already finished; holding it for the source minus the start is exact.
{
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.startFrame = kRate / 2; // start half a second in -> 1.5 s of sound left
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.5));
// Voice::start's own degenerate rule: a start at or past the end plays from the top,
// so the window covers the whole source rather than nothing.
s.startFrame = kRate * 2;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
s.startFrame = -1;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
}
// --- Gate with no loop under Varispeed: the read-stretch bound applies here too -------
// The Trigger branch has its own coverage below; this pins that the Gate exhaustion length
// takes the same bound, which is the branch a downward offset would otherwise truncate.
{
SampleData s = dialedSample(/*frames=*/kRate); // 1 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = -12.0; // an octave down == half speed at the peak
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0));
// Preserve reads at the source rate, so the same dial bounds nothing.
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.0));
}
// --- Trigger: the window is the play span, which ignores the note's length ----------
@@ -91,8 +130,8 @@ int main() {
CHECK(r.captureStartSeconds == 0.0);
CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds));
// A span SHORTER than the quarter note closes the window early rather than padding
// it out to note-off — the sound is over, and a negative end offset is legal.
// A shorter %-length is a shorter window, with no floor under it: the sound is over
// when the span is, and note-off means nothing to a Trigger voice.
s.play.trigger.lengthFraction = 0.1; // 0.2 s
const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
CHECK(!brief.windowCollapsed);
@@ -127,9 +166,9 @@ int main() {
{{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar);
const NoteProgram soft =
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(1));
defaultBakeProgram(s, kRate, oneBar(), Velocity::of(1));
const NoteProgram hard =
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity::of(127));
defaultBakeProgram(s, kRate, oneBar(), Velocity::of(127));
CHECK(soft.velocity.value() == 1);
CHECK(hard.velocity.value() == 127);
const ResolvedNote softR = resolveNote(soft, at(120.0));
@@ -148,9 +187,9 @@ int main() {
CHECK(bakeWindowNeedsHold(s));
// The window follows Hold rather than the source, so a longer Hold is a longer file.
const ResolvedNote bar = resolveNote(
defaultBakeProgram(s, kRate, at(120.0), oneBar(), Velocity{}), at(120.0));
defaultBakeProgram(s, kRate, oneBar(), Velocity{}), at(120.0));
const ResolvedNote twoBars = resolveNote(
defaultBakeProgram(s, kRate, at(120.0),
defaultBakeProgram(s, kRate,
makeDivision(3, DivisionModifier::Straight), Velocity{}),
at(120.0));
CHECK(near(bar.noteOffSeconds, 2.0));