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
+65 -5
View File
@@ -10,6 +10,7 @@
#include <cmath>
#include <cstdio>
#include <optional>
using namespace reasampler;
using namespace reasampler::instrument::bake;
@@ -34,12 +35,14 @@ constexpr std::int64_t kPad = kDeclickFrames;
// expectations below read as arithmetic rather than as magic.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
Tempo tempo() {
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
Tempo tempoOf(double bpm) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; }
return t.value_or(Tempo::fromBpm(120.0).value());
}
Tempo tempo() { return tempoOf(kBpm); }
// Flat DC so a level reading is unambiguous: any departure from 0.5 is the envelope, the
// filter or a ring-out, never the source's own shape.
SampleData dcSample(std::size_t frames) {
@@ -66,7 +69,7 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
// comparison a measurement of the derived end rather than of a second derivation.
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
int velocity = 100) {
NoteProgram p = defaultBakeProgram(s, kRate, tempo(), hold, Velocity::of(velocity));
NoteProgram p = defaultBakeProgram(s, kRate, hold, Velocity::of(velocity));
if (extraMs != 0.0)
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
return p;
@@ -136,6 +139,63 @@ int main() {
CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
}
// --- Gate, no loop, a source LONGER than the note-length ladder ---------------------
// The regression the exact-duration seam exists for. A derived length used to be rounded
// onto the musical-division ladder, whose longest rung is kMaxDivisionBeats (384 beats):
// a source past that took the top rung, so note-off — and the window with it — landed
// INSIDE the sound. Rendered at a low rate and a fast tempo so the case is 400 beats long
// without being twenty million frames; nothing here depends on either number but the
// beats it puts the source at.
{
constexpr int kSlowRate = 8000;
const Tempo fast = tempoOf(480.0); // 384 beats == 48 s at this tempo
constexpr std::int64_t kFrames = kSlowRate * 50; // 50 s == 400 beats: past the ladder
SampleData s = dcSample(static_cast<std::size_t>(kFrames));
s.sampleRate = kSlowRate;
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
const NoteProgram p =
defaultBakeProgram(s, kSlowRate, oneBar(), Velocity::of(100));
const ResolvedNote r = resolveNote(p, fast);
CHECK(r.noteOffSeconds > 49.9 && r.noteOffSeconds < 50.1); // 50 s, not the 48 s rung
const std::optional<BakePlan> plan = planBake(r, kSlowRate, 60).plan;
CHECK(plan.has_value());
if (plan) {
CHECK(plan->totalFrames == kFrames + kPad);
const BakeAudio whole = renderBake(s, *plan, kUnity);
// Full level across the two seconds the saturated rung used to cut, and the file
// still ends on the declick ramp rather than on a hard edge.
CHECK(peakAt(whole, kSlowRate * 48, kFrames) > 0.4);
CHECK(lastFrameLevel(whole) < 1e-3);
}
}
// --- Gate, no loop, a START POINT under Varispeed: both terms of the exhaustion ------
// The two derivation terms this branch has that the cases above do not exercise: the
// window covers frameCount MINUS the start, and that remainder is stretched by the
// deepest downward offset. Getting either wrong shortens the window.
{
SampleData s = dcSample(48000); // 1 s of source
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.startFrame = 24000; // half of it left to play
s.play.pitchEngine = PitchEngine::Varispeed;
// A flat velocity->pitch curve at half depth: an octave down (the range is 24
// semitones) for the note's whole lifetime, so the read really does run at half rate
// to the end rather than for one envelope stage.
s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
{{0.0, -0.5}, {127.0, -0.5}}, reasampler::instrument::engine::CurveDomain::Bipolar);
// (48000 - 24000) source frames at half rate == 48000 output frames. Reading either
// term wrong halves or doubles this.
CHECK(derivedFrames(s) == 48000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 47000, 48000) > 0.4); // still sounding at the derived end
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0); // and nothing past it
}
// --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
// A looped Gate voice sounds for as long as it is held, so no derivation supplies a
// duration — this is the one case the predicate names, and the window follows Hold.
@@ -254,7 +314,7 @@ int main() {
// --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
// A drawn contour covers the full sample length, so the engine folds lengthFraction to
// 1.0 (effectiveLengthFraction, trigger_seam.h). The stored %-knob is inert but still
// 1.0 (effectiveLengthFraction, play_params.h). The stored %-knob is inert but still
// saved, and reading it raw here cut this window to a quarter of the take.
{
SampleData s = dcSample(48000);