524 lines
25 KiB
C++
524 lines
25 KiB
C++
// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
|
|
// audible result of the dialed sound? Every case renders through the real chain
|
|
// (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then re-renders the SAME
|
|
// sound with a longer window, so "what fell outside" is measured rather than argued.
|
|
// Trailing silence is a pass; signal past the derived end is a truncation.
|
|
|
|
#include "../src/core/instrument/bake/bake_plan.h"
|
|
#include "../src/core/instrument/bake/bake_render.h"
|
|
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the pad under test)
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <optional>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::bake;
|
|
using namespace reasampler::instrument::note;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
namespace {
|
|
|
|
constexpr int kRate = 48000;
|
|
constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames
|
|
constexpr double kUnity = 1.0;
|
|
constexpr double kSilence = 1e-6;
|
|
// The derived window is a property of the voice chain, not of the master stage: every
|
|
// measurement here reads the render with the limiter bypassed.
|
|
constexpr bool kNoLimiter = false;
|
|
|
|
// The declick pad every derived window carries. Read off the engine's own constants, so a
|
|
// retuned ramp moves this file's expectations with it rather than against them.
|
|
constexpr std::int64_t kPad = kDeclickFrames;
|
|
|
|
// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
|
|
// expectations below read as arithmetic rather than as magic.
|
|
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
|
|
|
|
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) {
|
|
SampleData s;
|
|
s.frames.assign(frames, 0.5f);
|
|
s.sampleRate = kRate;
|
|
s.rootNote = 60;
|
|
return s;
|
|
}
|
|
|
|
double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
|
double peak = 0.0;
|
|
if (from < 0) from = 0;
|
|
for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) {
|
|
const double v = std::fabs(static_cast<double>(
|
|
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)]));
|
|
if (v > peak) peak = v;
|
|
}
|
|
return peak;
|
|
}
|
|
|
|
// The last frame of the file that carries any signal at all — where the voice ACTUALLY stopped.
|
|
// A measurement of the engine, never a second evaluation of the derivation under test. -1 when
|
|
// the render is silent throughout.
|
|
std::int64_t lastSoundingFrame(const BakeAudio& audio) {
|
|
for (std::int64_t f = audio.frameCount() - 1; f >= 0; --f) {
|
|
if (std::fabs(static_cast<double>(
|
|
audio.interleaved[static_cast<std::size_t>(f * audio.channelCount)])) > kSilence) {
|
|
return f;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
|
|
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
|
|
// 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, hold, Velocity::of(velocity));
|
|
if (extraMs != 0.0)
|
|
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
|
|
return p;
|
|
}
|
|
|
|
std::optional<BakePlan> planOf(const NoteProgram& p) {
|
|
return planBake(resolveNote(p, tempo()), kRate, 60).plan;
|
|
}
|
|
|
|
// The render the shell would produce, plus `extraMs` of extra window.
|
|
BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar(),
|
|
int velocity = 100) {
|
|
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs, hold, velocity));
|
|
if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
|
|
return renderBake(s, *plan, kUnity, kNoLimiter);
|
|
}
|
|
|
|
std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
|
|
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
|
|
return plan ? plan->totalFrames : -1;
|
|
}
|
|
|
|
// Where the dialed sound stops when NOTHING cuts it: the same sound programmed with a
|
|
// deliberately long note and a window to match. This is the reference a derived window is
|
|
// judged against, and it has to be measured rather than recomputed — an under-derived Gate
|
|
// window truncates by releasing the note EARLY, which leaves no signal outside the file at all
|
|
// and so is invisible to "nothing past the end".
|
|
std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) {
|
|
NoteProgram p = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(100));
|
|
p.length = lengthOfSeconds(heldSeconds);
|
|
p.end = EndOffset(offsetFromMs(200.0));
|
|
const std::optional<BakePlan> plan = planOf(p);
|
|
if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; }
|
|
return lastSoundingFrame(renderBake(s, *plan, kUnity, kNoLimiter));
|
|
}
|
|
|
|
// The last frame of the file, which is where a hard cut shows up.
|
|
double lastFrameLevel(const BakeAudio& audio) {
|
|
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
|
|
: 0.0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
// ================================ GATE ==========================================
|
|
|
|
// --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
|
|
// The read head frees the voice at exhaustion whether or not the gate is still down, so
|
|
// the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
|
|
{
|
|
SampleData s = dcSample(96000);
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.releaseFrames = 4800; // 100 ms
|
|
|
|
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
|
|
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
|
|
// Full level right up to exhaustion, and nothing at all past it — the note outlived
|
|
// its own release, so the release window is trailing silence, not a truncated tail.
|
|
CHECK(peakAt(wide, 95000, 96000) > 0.4);
|
|
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
|
|
}
|
|
|
|
// --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
|
|
// The window used to be a constant quarter note, which released a 2 s attack a quarter of
|
|
// the way up. Deriving the hold from source exhaustion is what closes that.
|
|
{
|
|
SampleData s = dcSample(192000); // 4 s
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.attackFrames = 96000; // 2 s
|
|
s.play.adsr.releaseFrames = 0;
|
|
|
|
CHECK(derivedFrames(s) == 192000 + kPad);
|
|
const BakeAudio derived = bakeWith(s, 0.0);
|
|
// The whole attack, at full level — under the old constant quarter note this peaked
|
|
// between 0.10 and 0.14.
|
|
CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
|
|
// …and a longer window adds nothing: the derivation already held everything.
|
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
|
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, kNoLimiter);
|
|
// 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.
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.loop = SampleLoop{true, 0, 24000};
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.releaseFrames = 4800;
|
|
|
|
CHECK(bakeWindowNeedsHold(s));
|
|
|
|
// One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
|
|
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
|
const BakeAudio bar = bakeWith(s, 0.0);
|
|
CHECK(peakAt(bar, 90000, 96000) > 0.4); // still cycling at note-off
|
|
CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01); // released to its own floor
|
|
|
|
// A different Hold is a different window, in the same proportion — the knob really is
|
|
// what the derivation reads here.
|
|
const Division twoBars = makeDivision(3, DivisionModifier::Straight);
|
|
CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
|
|
const BakeAudio held = bakeWith(s, 0.0, twoBars);
|
|
CHECK(peakAt(held, 96000, 192000) > 0.4); // full level well past one bar
|
|
}
|
|
|
|
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.play.playMode = PlayMode::Gate;
|
|
CHECK(!bakeWindowNeedsHold(s)); // no loop at all
|
|
s.loop = SampleLoop{true, 0, 24000};
|
|
CHECK(bakeWindowNeedsHold(s));
|
|
s.loop = SampleLoop{true, 0, 48001}; // reaches past the PCM
|
|
CHECK(!bakeWindowNeedsHold(s)); // …which resolveLoop refuses
|
|
s.loop = SampleLoop{true, 24000, 12000}; // inverted
|
|
CHECK(!bakeWindowNeedsHold(s));
|
|
s.loop = SampleLoop{true, 0, 24000};
|
|
s.play.playMode = PlayMode::Trigger; // Trigger has no sustain loop
|
|
CHECK(!bakeWindowNeedsHold(s));
|
|
}
|
|
|
|
// --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
|
|
// Preserve rings its last real output out instead of hard-cutting it; the derived window
|
|
// is padded by exactly that ramp, so the file ends at silence.
|
|
{
|
|
SampleData s = dcSample(24000); // exhausts at half a bar
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
s.play.adsr.releaseFrames = 0;
|
|
|
|
CHECK(derivedFrames(s) == 24000 + kPad);
|
|
const BakeAudio derived = bakeWith(s, 0.0);
|
|
CHECK(peakAt(derived, 23990, 24000) > 0.4); // full level while the source lasts
|
|
CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05); // the ramp, inside the file
|
|
CHECK(lastFrameLevel(derived) < 1e-3); // and the file ends at silence
|
|
// Nothing at all past the pad: the window is not merely long, it is exactly enough.
|
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
|
CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
|
|
}
|
|
|
|
// --- The resonant filter cannot ring past the amp gate ------------------------------
|
|
// pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
|
|
// gated by the envelope the window already holds. Not a tail contributor.
|
|
{
|
|
SampleData s = dcSample(96000);
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.releaseFrames = 4800;
|
|
s.play.filter.enabled = true;
|
|
s.play.filter.settings.cutoffNorm = 0.05f;
|
|
s.play.filter.settings.resonanceNorm = 1.0f;
|
|
|
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
|
|
CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded
|
|
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it
|
|
}
|
|
|
|
// ================================ TRIGGER =======================================
|
|
|
|
// --- Trigger, Varispeed, a constant deep downward pitch offset: UPPER BOUND ---------
|
|
// The window is scaled by the deepest reachable offset, so a shallower excursion leaves
|
|
// trailing silence — long, but never short.
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.peakSemitones = -24.0; // two octaves down
|
|
s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span
|
|
|
|
// 1 s of source stretched by 2^(24/12) == 4.
|
|
CHECK(derivedFrames(s) == 192000 + kPad);
|
|
const BakeAudio derived = bakeWith(s, 0.0);
|
|
// The offset only holds for the envelope's own span, so the read finishes near
|
|
// 84000 frames — inside the window, with the balance as trailing silence.
|
|
CHECK(peakAt(derived, 80000, 84000) > 0.4);
|
|
CHECK(peakAt(derived, 90000, 192000) < kSilence);
|
|
}
|
|
|
|
// --- Trigger + Preserve (the product-default engine): the ring-out is HELD -----------
|
|
// Preserve's read reaches playEnd where the un-padded window used to close, leaving the
|
|
// whole terminal declick outside it — a hard cut at full level, the very click the ramp
|
|
// exists to remove. The pad is what closes that.
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
|
|
CHECK(derivedFrames(s) == 48000 + kPad);
|
|
const BakeAudio derived = bakeWith(s, 0.0);
|
|
CHECK(peakAt(derived, 47990, 48000) > 0.4); // full level at the source's own end
|
|
CHECK(peakAt(derived, 48000, 48001) > 0.49); // the ramp opens at that same level…
|
|
CHECK(lastFrameLevel(derived) < 1e-3); // …and the file ends at silence
|
|
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
|
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
|
|
}
|
|
|
|
// --- 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, 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);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigger.lengthFraction = 0.25; // inert in the engine, and now in the window
|
|
s.play.ampSpline.mode = EnvMode::Spline;
|
|
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
|
|
|
|
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
|
|
|
|
const BakeAudio derived = bakeWith(s, 0.0);
|
|
// Full level across the 36000 frames a raw read of the stored knob used to cut. Both
|
|
// spans start past the old 12000-frame end, so a truncated window reads 0 here.
|
|
CHECK(peakAt(derived, 36000, 40000) > 0.4);
|
|
CHECK(peakAt(derived, 47000, 48000) > 0.4);
|
|
// A %-knob that IS live still shortens the window — the fold is conditional, not a
|
|
// blanket ignore.
|
|
SampleData staged = s;
|
|
staged.play.ampSpline.mode = EnvMode::Staged;
|
|
CHECK(derivedFrames(staged) == 12000 + kPad);
|
|
}
|
|
|
|
// ============================ RATE AND PITCH ====================================
|
|
|
|
// The one judgement every case below makes: the derived window holds the WHOLE free-running
|
|
// sound (the derived render stops exactly where the uncut one does), and it is exactly
|
|
// enough rather than merely long. `heldSeconds` only has to exceed the free-running length.
|
|
const auto windowHoldsTheWholeNote = [&](const SampleData& s, double heldSeconds,
|
|
const char* what) {
|
|
const std::int64_t trueEnd = freeRunningEnd(s, heldSeconds);
|
|
const std::int64_t derived = derivedFrames(s);
|
|
const std::int64_t got = lastSoundingFrame(bakeWith(s, 0.0));
|
|
const bool held = trueEnd >= 0 && derived > trueEnd && got == trueEnd;
|
|
CHECK(held);
|
|
CHECK(held && derived - trueEnd <= kPad + 8);
|
|
if (!(held && derived - trueEnd <= kPad + 8)) {
|
|
std::printf(" %s: free-running end %lld, derived render end %lld, window %lld\n",
|
|
what, static_cast<long long>(trueEnd), static_cast<long long>(got),
|
|
static_cast<long long>(derived));
|
|
}
|
|
};
|
|
|
|
// --- Rate scales the window under BOTH engines, in both derived branches --------------
|
|
// Rate IS the read rate: Varispeed folds it into the read increment, Preserve feeds the
|
|
// stretcher at it. Either way a 50 % rate doubles how long the source takes to play out and
|
|
// a 200 % one halves it, so a window blind to Rate truncates by half at the slow end and
|
|
// prints a file of trailing silence at the fast one.
|
|
{
|
|
for (PitchEngine eng : {PitchEngine::Varispeed, PitchEngine::Preserve}) {
|
|
for (PlayMode mode : {PlayMode::Trigger, PlayMode::Gate}) {
|
|
for (double rate : {0.5, 0.75, 1.0, 1.5, 2.0}) {
|
|
SampleData s = dcSample(48000); // 1 s; 2 s at the slowest rate
|
|
s.play.playMode = mode;
|
|
s.play.pitchEngine = eng;
|
|
s.play.adsr.releaseFrames = 0;
|
|
s.play.playRate = rate;
|
|
char what[64];
|
|
std::snprintf(what, sizeof(what), "eng %d mode %d rate %.2f",
|
|
static_cast<int>(eng), static_cast<int>(mode), rate);
|
|
windowHoldsTheWholeNote(s, 3.0, what);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- A downward Pitch offset stretches the window under VARISPEED only ---------------
|
|
// It is a factor of the read increment there and a shifter transpose under Preserve, so the
|
|
// window follows it in one engine and not the other. Both must still hold the whole note.
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.pitchOffsetSemitones = -12.0; // half rate for the note's whole lifetime
|
|
|
|
CHECK(derivedFrames(s) == 96000 + kPad);
|
|
windowHoldsTheWholeNote(s, 3.0, "varispeed pitch -12");
|
|
|
|
SampleData p = s;
|
|
p.play.pitchEngine = PitchEngine::Preserve;
|
|
CHECK(derivedFrames(p) == 48000 + kPad); // the read rate never moved
|
|
windowHoldsTheWholeNote(p, 3.0, "preserve pitch -12");
|
|
|
|
// An UPWARD offset bounds nothing — the read only gets faster — so the window keeps the
|
|
// un-stretched span and the balance is trailing silence, on the same asymmetry the
|
|
// velocity->pitch term already takes.
|
|
SampleData up = s;
|
|
up.play.pitchOffsetSemitones = 12.0;
|
|
CHECK(derivedFrames(up) == 48000 + kPad);
|
|
const BakeAudio wideUp = bakeWith(up, /*extraMs=*/500.0);
|
|
CHECK(peakAt(wideUp, 48000 + kPad, wideUp.frameCount()) == 0.0);
|
|
}
|
|
|
|
// --- Rate and Pitch COMPOUND, because the voice folds them into one multiply ----------
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.playRate = 0.5;
|
|
s.play.pitchOffsetSemitones = -12.0; // together: a quarter-speed read
|
|
|
|
CHECK(derivedFrames(s) == 192000 + kPad);
|
|
windowHoldsTheWholeNote(s, 5.0, "varispeed rate 0.5 x pitch -12");
|
|
}
|
|
|
|
// --- Gate over a sustain loop is Hold's, and Rate does not touch it -------------------
|
|
// The note length there is the user's Hold in wall clock and the release is ticked per
|
|
// output frame, so neither term of the stretch applies — the one derived branch that must
|
|
// NOT move when Rate does.
|
|
{
|
|
SampleData s = dcSample(48000);
|
|
s.loop = SampleLoop{true, 0, 24000};
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.releaseFrames = 4800;
|
|
CHECK(bakeWindowNeedsHold(s));
|
|
|
|
const std::int64_t unity = derivedFrames(s);
|
|
for (double rate : {0.5, 2.0}) {
|
|
SampleData r = s;
|
|
r.play.playRate = rate;
|
|
CHECK(derivedFrames(r) == unity);
|
|
}
|
|
}
|
|
|
|
// ============================== VELOCITY ========================================
|
|
|
|
// --- The bake renders at the velocity it is handed ----------------------------------
|
|
// Three velocity curves are live, so a sound auditioned at 120 does not bake as one
|
|
// auditioned at 40. linear() maps velocity/127 onto amp gain.
|
|
{
|
|
SampleData s = dcSample(24000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.velocityCurve = VelocityCurve::linear();
|
|
|
|
const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
|
|
const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
|
|
const double softPeak = peakAt(soft, 0, 24000);
|
|
const double hardPeak = peakAt(hard, 0, 24000);
|
|
// 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
|
|
CHECK(softPeak > 0.15 && softPeak < 0.17);
|
|
CHECK(hardPeak > 0.46 && hardPeak < 0.48);
|
|
CHECK(hardPeak > softPeak * 2.0);
|
|
}
|
|
|
|
// ============================== REFUSALS ========================================
|
|
|
|
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
|
|
{
|
|
SampleData s = dcSample(1'100'000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.peakSemitones = -48.0;
|
|
s.play.pitchEnv.shape.holdFraction = 1.0;
|
|
const PlannedBake tooLong =
|
|
planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
|
|
CHECK(!tooLong.plan.has_value());
|
|
CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);
|
|
|
|
// One octave shallower is inside the ceiling — the refusal above is the window, not
|
|
// the fixture.
|
|
s.play.pitchEnv.peakSemitones = -24.0;
|
|
CHECK(planOf(derivedProgram(s, 0.0)).has_value());
|
|
}
|
|
|
|
// --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
|
|
{
|
|
NoteProgram p;
|
|
p.end = EndOffset(offsetFromMs(-5000.0)); // ends long before note-off: collapsed
|
|
const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
|
|
CHECK(!empty.plan.has_value());
|
|
CHECK(empty.refusal == BakeRefusal::EmptyWindow);
|
|
}
|
|
|
|
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|