1526 lines
68 KiB
C++
1526 lines
68 KiB
C++
// Standalone tests for reasampler::sampler_core — no VST3, no REAPER, no test
|
|
// framework. Same fast build/run loop as bank_model_tests / peaks_tests: feed known
|
|
// inputs, assert the engine's behavior.
|
|
//
|
|
// Covers (PLAN.md S3 / CONTEXT.md §Phase S pure core):
|
|
// 1. polyphonic allocation — N notes -> N voices; note-off releases the right voice.
|
|
// 2. voice stealing at the bound — deterministic policy (release-first, then oldest).
|
|
// 3. ADSR envelope shape vs a known signal, incl. release-before-sustain.
|
|
// 4. repitch ratio correctness across +/-1 octave from root incl. unity, asserted on
|
|
// the observed period of a synthesized sine.
|
|
// 5. loop-point sustain — held note past sample end loops [start,end) seamlessly;
|
|
// zero-length loop and absent-loop behavior.
|
|
// 6. keymap: chromatic-from-single-root; zoned ranges with boundary notes; velocity
|
|
// -> volume; out-of-zone note -> defined no-play.
|
|
//
|
|
// The plain-data boundary (no VST3/REAPER types in the core) is enforced STRUCTURALLY
|
|
// by the CMake target linking neither SDK — this file includes only sampler_core.h +
|
|
// the standard library, which is itself the compile-time proof.
|
|
|
|
#include "../src/vst/sampler_core.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
using namespace reasampler;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
static bool approx(double a, double b, double tol) { return std::fabs(a - b) <= tol; }
|
|
|
|
constexpr double kPi = 3.14159265358979323846;
|
|
|
|
// A silent (all-1.0) sample so a rendered voice's output tracks the envelope * velocity
|
|
// directly (DC of amplitude 1). Root at note 60 by default.
|
|
static SampleData dcSample(std::size_t frames, int rootNote = 60) {
|
|
SampleData s;
|
|
s.frames.assign(frames, 1.0f);
|
|
s.rootNote = rootNote;
|
|
return s;
|
|
}
|
|
|
|
// A mono sine of `cycles` periods over `frames` frames — used to observe repitch by
|
|
// measuring the played-back period.
|
|
static SampleData sineSample(std::size_t frames, double cycles, int rootNote = 60) {
|
|
SampleData s;
|
|
s.frames.resize(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
s.frames[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
|
|
static_cast<double>(i) / static_cast<double>(frames)));
|
|
}
|
|
s.rootNote = rootNote;
|
|
return s;
|
|
}
|
|
|
|
// An ADSR that stays fully open (level 1) forever while held, so voice output equals
|
|
// velocity gain — isolates allocation/repitch/loop tests from envelope shaping.
|
|
static AdsrParams flatAdsr() {
|
|
AdsrParams a;
|
|
a.attackFrames = 0;
|
|
a.decayFrames = 0;
|
|
a.sustainLevel = 1.0;
|
|
a.releaseFrames = 0; // note-off -> instant silence.
|
|
return a;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Keymap resolution.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testChromaticSingleRoot() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
|
|
CHECK(km.zones.size() == 1);
|
|
// Every note in 0..127 resolves to the single zone.
|
|
for (int n = 0; n <= 127; ++n) {
|
|
ZoneResolution r = km.resolve(n, 100);
|
|
CHECK(r.matched);
|
|
CHECK(r.zoneIndex == 0);
|
|
}
|
|
}
|
|
|
|
static void testZonedRangesBoundaries() {
|
|
Keymap km;
|
|
km.samples.push_back(dcSample(100, 48)); // low sample
|
|
km.samples.push_back(dcSample(100, 72)); // high sample
|
|
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
|
|
// correct zone; a first-match order test would catch an off-by-one.
|
|
km.zones.push_back(KeyZone{36, 59, 48, 0});
|
|
km.zones.push_back(KeyZone{60, 83, 72, 1});
|
|
|
|
CHECK(km.resolve(36, 100).matched);
|
|
CHECK(km.resolve(36, 100).zoneIndex == 0);
|
|
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0
|
|
CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1
|
|
CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1
|
|
|
|
// Out of every zone -> defined no-play (not a match, not zone 0).
|
|
CHECK(!km.resolve(35, 100).matched);
|
|
CHECK(!km.resolve(84, 100).matched);
|
|
CHECK(!km.resolve(127, 100).matched);
|
|
}
|
|
|
|
static void testFirstMatchOnOverlap() {
|
|
// Overlapping zones: the earlier zone wins (documented deterministic rule).
|
|
Keymap km;
|
|
km.samples.push_back(dcSample(10, 60));
|
|
km.samples.push_back(dcSample(10, 60));
|
|
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first
|
|
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all
|
|
CHECK(km.resolve(60, 100).zoneIndex == 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Repitch ratio correctness.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testPitchRatioMath() {
|
|
CHECK(approx(pitchRatio(60, 60), 1.0, 1e-9)); // unity at root
|
|
CHECK(approx(pitchRatio(72, 60), 2.0, 1e-9)); // +1 octave
|
|
CHECK(approx(pitchRatio(48, 60), 0.5, 1e-9)); // -1 octave
|
|
CHECK(approx(pitchRatio(61, 60), std::pow(2.0, 1.0 / 12.0), 1e-9)); // +1 semitone
|
|
}
|
|
|
|
// --- S-VIEW-6 key-tracking ratio math (pure), asserted at 0 / 100 / 200% + off-root. ---
|
|
static void testKeyTrackedRatioMath() {
|
|
// 100% (keyTrack == 1.0) is standard 12-tone-ET and BIT-IDENTICAL to pitchRatio: the argument
|
|
// to std::pow is (note-root)*1.0, exact in IEEE-754, so the same call yields the same bits.
|
|
for (int note = 0; note <= 127; ++note) {
|
|
CHECK(keyTrackedRatio(note, 60, 1.0) == pitchRatio(note, 60)); // exact equality, not approx
|
|
}
|
|
CHECK(approx(keyTrackedRatio(72, 60, 1.0), 2.0, 1e-9)); // +1 octave tracked normally
|
|
CHECK(approx(keyTrackedRatio(48, 60, 1.0), 0.5, 1e-9)); // -1 octave tracked normally
|
|
|
|
// 0% (keyTrack == 0.0): no tracking. Every key — including off-root ones — plays root pitch.
|
|
CHECK(approx(keyTrackedRatio(60, 60, 0.0), 1.0, 1e-12)); // at root: unity (trivially)
|
|
CHECK(approx(keyTrackedRatio(72, 60, 0.0), 1.0, 1e-12)); // an octave up STILL plays root pitch
|
|
CHECK(approx(keyTrackedRatio(48, 60, 0.0), 1.0, 1e-12)); // an octave down STILL plays root pitch
|
|
CHECK(approx(keyTrackedRatio(67, 60, 0.0), 1.0, 1e-12)); // an off-root 5th STILL plays root pitch
|
|
|
|
// 200% (keyTrack == 2.0): double-rate tracking. The semitone offset is doubled, so a +12 key
|
|
// plays as if +24 (two octaves, ratio 4.0), a -12 key as -24 (ratio 0.25).
|
|
CHECK(approx(keyTrackedRatio(72, 60, 2.0), 4.0, 1e-9)); // +12 -> +24 semis -> 4.0
|
|
CHECK(approx(keyTrackedRatio(48, 60, 2.0), 0.25, 1e-9)); // -12 -> -24 semis -> 0.25
|
|
CHECK(approx(keyTrackedRatio(60, 60, 2.0), 1.0, 1e-12)); // root is unity at ANY keyTrack
|
|
|
|
// Off-root at 200% for a single semitone: +1 semi -> +2 semis -> 2^(2/12).
|
|
CHECK(approx(keyTrackedRatio(61, 60, 2.0), std::pow(2.0, 2.0 / 12.0), 1e-9));
|
|
// An arbitrary intermediate scalar (50%): +12 key tracks as +6 semis -> 2^(6/12) = sqrt(2).
|
|
CHECK(approx(keyTrackedRatio(72, 60, 0.5), std::pow(2.0, 6.0 / 12.0), 1e-9));
|
|
}
|
|
|
|
// Observe repitch on the rendered signal: a voice played an octave above root should
|
|
// advance through the sample twice as fast, so a sine's observed period halves. We
|
|
// measure the period by counting the interval between positive-going zero crossings.
|
|
static double observedPeriodFrames(const std::vector<AudioSample>& out) {
|
|
std::vector<std::size_t> upCrossings;
|
|
for (std::size_t i = 1; i < out.size(); ++i) {
|
|
if (out[i - 1] <= 0.0f && out[i] > 0.0f) upCrossings.push_back(i);
|
|
}
|
|
if (upCrossings.size() < 2) return 0.0;
|
|
// Average spacing between crossings.
|
|
double sum = 0.0;
|
|
for (std::size_t i = 1; i < upCrossings.size(); ++i) {
|
|
sum += static_cast<double>(upCrossings[i] - upCrossings[i - 1]);
|
|
}
|
|
return sum / static_cast<double>(upCrossings.size() - 1);
|
|
}
|
|
|
|
static void testRepitchObservedPeriod() {
|
|
// A sine of 20 cycles over 8000 frames -> native period 400 frames at unity.
|
|
const std::size_t frames = 8000;
|
|
const double cycles = 20.0;
|
|
const double nativePeriod = static_cast<double>(frames) / cycles; // 400
|
|
|
|
// Unity: played at root, observed period ~= native.
|
|
{
|
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, frames);
|
|
double p = observedPeriodFrames(out);
|
|
CHECK(approx(p, nativePeriod, 2.0));
|
|
}
|
|
// +1 octave: advances 2x, observed period halves.
|
|
{
|
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(72, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, frames / 2); // half as many frames covers the whole sample
|
|
double p = observedPeriodFrames(out);
|
|
CHECK(approx(p, nativePeriod / 2.0, 2.0));
|
|
}
|
|
// -1 octave: advances 0.5x, observed period doubles.
|
|
{
|
|
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(48, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, frames);
|
|
double p = observedPeriodFrames(out);
|
|
CHECK(approx(p, nativePeriod * 2.0, 4.0));
|
|
}
|
|
}
|
|
|
|
// --- S-VIEW-6 keyTrack reaches the VARISPEED engine: observed period tracks the scalar. ---
|
|
static void testKeyTrackVarispeedObservedPeriod() {
|
|
const std::size_t frames = 8000;
|
|
const double cycles = 20.0;
|
|
const double nativePeriod = static_cast<double>(frames) / cycles; // 400 at unity
|
|
|
|
auto periodAt = [&](int note, double keyTrack) -> double {
|
|
SampleData s = sineSample(frames, cycles, 60);
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
// The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
|
|
km.zones[0].keyTrack = keyTrack;
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(note, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, frames);
|
|
return observedPeriodFrames(out);
|
|
};
|
|
|
|
// note 72 (+1 octave). At 100% it plays an octave up (period halves ~200). At 0% it plays at
|
|
// ROOT pitch (period ~native 400 — no tracking). The observed periods must differ by ~2x, which
|
|
// proves the scalar drove the Varispeed read rate.
|
|
const double at100 = periodAt(72, 1.0);
|
|
const double at0 = periodAt(72, 0.0);
|
|
CHECK(approx(at100, nativePeriod / 2.0, 3.0)); // 100%: tracked an octave up
|
|
CHECK(approx(at0, nativePeriod, 3.0)); // 0%: no tracking, plays root pitch
|
|
}
|
|
|
|
// --- S-VIEW-6 keyTrack reaches the PRESERVE engine: at 0% an off-root note collapses to the root
|
|
// shift (unity), producing output identical to playing the root note. Proves keyTrack feeds
|
|
// baseRatio_ -> the Preserve shift amount (not merely the Varispeed read rate). ---
|
|
static void testKeyTrackPreserveShiftCollapsesAtZero() {
|
|
const std::size_t frames = 2000;
|
|
const std::size_t window = 512;
|
|
const double cycles = 40.0;
|
|
|
|
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
|
|
SampleData s = sineSample(frames, cycles, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
km.zones[0].keyTrack = keyTrack;
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
|
|
eng.noteOn(note, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, frames);
|
|
return out;
|
|
};
|
|
|
|
// An off-root note (+7) at keyTrack 0.0 sets the Preserve shift to the root ratio (1.0) — the
|
|
// shifter is pass-through, so the output must be BIT-IDENTICAL to playing the ROOT note (whose
|
|
// offset is 0, also shift 1.0). If keyTrack only touched Varispeed, these would differ.
|
|
const std::vector<AudioSample> offRootNoTrack = renderPreserve(67, 0.0);
|
|
const std::vector<AudioSample> rootRef = renderPreserve(60, 1.0);
|
|
CHECK(offRootNoTrack.size() == rootRef.size());
|
|
bool identical = offRootNoTrack.size() == rootRef.size();
|
|
for (std::size_t i = 0; i < offRootNoTrack.size() && identical; ++i) {
|
|
if (offRootNoTrack[i] != rootRef[i]) identical = false;
|
|
}
|
|
CHECK(identical); // 0% tracking collapses the Preserve shift to unity, exactly like the root
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. ADSR envelope shape vs a known signal.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testAdsrShape() {
|
|
AdsrParams p;
|
|
p.attackFrames = 10;
|
|
p.decayFrames = 10;
|
|
p.sustainLevel = 0.5;
|
|
p.releaseFrames = 10;
|
|
AdsrEnvelope env;
|
|
env.configure(p);
|
|
env.noteOn();
|
|
|
|
// Attack: 0 -> ramps up. Frame 0 == 0, rising each frame.
|
|
double prev = -1.0;
|
|
for (int i = 0; i < 10; ++i) {
|
|
double v = env.tick();
|
|
CHECK(v >= prev); // monotonic non-decreasing through attack
|
|
CHECK(v >= 0.0 && v <= 1.0);
|
|
prev = v;
|
|
}
|
|
// Decay: from 1.0 down toward sustain 0.5, monotonic non-increasing.
|
|
prev = 2.0;
|
|
for (int i = 0; i < 10; ++i) {
|
|
double v = env.tick();
|
|
CHECK(v <= prev + 1e-9); // non-increasing through decay
|
|
CHECK(v >= 0.5 - 1e-9); // never below sustain during decay
|
|
prev = v;
|
|
}
|
|
// Sustain: holds 0.5 indefinitely.
|
|
for (int i = 0; i < 100; ++i) {
|
|
CHECK(approx(env.tick(), 0.5, 1e-9));
|
|
}
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
|
|
|
|
// Release: 0.5 -> 0 over 10 frames, then Finished + latched at 0.
|
|
env.noteOff();
|
|
prev = 1.0;
|
|
for (int i = 0; i < 10; ++i) {
|
|
double v = env.tick();
|
|
CHECK(v <= prev + 1e-9); // non-increasing through release
|
|
prev = v;
|
|
}
|
|
CHECK(env.finished());
|
|
for (int i = 0; i < 10; ++i) CHECK(approx(env.tick(), 0.0, 1e-12));
|
|
}
|
|
|
|
static void testAdsrReleaseBeforeSustain() {
|
|
// noteOff during the attack ramp releases from the PARTIAL level, not sustain.
|
|
AdsrParams p;
|
|
p.attackFrames = 100;
|
|
p.decayFrames = 10;
|
|
p.sustainLevel = 0.8;
|
|
p.releaseFrames = 20;
|
|
AdsrEnvelope env;
|
|
env.configure(p);
|
|
env.noteOn();
|
|
|
|
// Advance 50 frames into a 100-frame attack -> partial level ~0.5.
|
|
double last = 0.0;
|
|
for (int i = 0; i < 50; ++i) last = env.tick();
|
|
CHECK(last > 0.3 && last < 0.7); // partway up the attack ramp
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Attack);
|
|
|
|
env.noteOff();
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Release);
|
|
// First release frame must be at or below the partial level we left off at —
|
|
// NOT jump up to sustain 0.8. This is the release-before-sustain guarantee.
|
|
double firstRelease = env.tick();
|
|
CHECK(firstRelease <= last + 1e-9);
|
|
CHECK(firstRelease < p.sustainLevel); // proves it did not snap to sustain
|
|
// Decays to zero.
|
|
double prev = firstRelease;
|
|
for (int i = 0; i < 20; ++i) {
|
|
double v = env.tick();
|
|
CHECK(v <= prev + 1e-9);
|
|
prev = v;
|
|
}
|
|
CHECK(env.finished());
|
|
}
|
|
|
|
static void testAdsrZeroAttackDecay() {
|
|
// Zero attack + zero decay -> jumps straight to sustain on the first ticks.
|
|
AdsrParams p;
|
|
p.attackFrames = 0;
|
|
p.decayFrames = 0;
|
|
p.sustainLevel = 0.7;
|
|
p.releaseFrames = 5;
|
|
AdsrEnvelope env;
|
|
env.configure(p);
|
|
env.noteOn();
|
|
// Zero attack emits the attack peak (1.0) on frame 0 and immediately transitions
|
|
// through the (also zero-length) decay, so by frame 1 the envelope is holding
|
|
// sustain. The peak-at-boundary is the documented single-frame edge, not a bug.
|
|
CHECK(approx(env.tick(), 1.0, 1e-9)); // frame 0: attack peak
|
|
CHECK(approx(env.tick(), 0.7, 1e-9)); // frame 1: sustain
|
|
CHECK(approx(env.tick(), 0.7, 1e-9));
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Polyphonic allocation + note-off routing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testPolyphonicAllocation() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
|
|
VoiceEngine eng(8, km);
|
|
|
|
// Four simultaneous notes -> four active voices, each on a distinct voice.
|
|
std::size_t v60 = eng.noteOn(60, 100);
|
|
std::size_t v64 = eng.noteOn(64, 100);
|
|
std::size_t v67 = eng.noteOn(67, 100);
|
|
std::size_t v72 = eng.noteOn(72, 100);
|
|
CHECK(v60 != VoiceEngine::kNoVoice);
|
|
CHECK(eng.activeVoiceCount() == 4);
|
|
CHECK(v60 != v64 && v64 != v67 && v67 != v72 && v60 != v72);
|
|
|
|
// Note-off on 64 releases exactly one voice; with instant release it goes idle
|
|
// after the next render frame.
|
|
eng.noteOff(64);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(eng.activeVoiceCount() == 3);
|
|
|
|
// The still-held notes keep sounding.
|
|
eng.render(out, 1);
|
|
CHECK(eng.activeVoiceCount() == 3);
|
|
}
|
|
|
|
static void testNoteOffReleasesNewestSameNote() {
|
|
// Prove that noteOff releases the NEWEST (highest startOrder) instance of a
|
|
// re-triggered note, leaving the older voice in sustain.
|
|
//
|
|
// Two voices at distinct velocities so their output is distinguishable:
|
|
// "first" (older) -> velocity 64 -> gain ~0.504 (G_old)
|
|
// "second" (newer) -> velocity 127 -> gain 1.0 (G_new)
|
|
//
|
|
// With a DC-1 sample and sustain=1, while both are held:
|
|
// render sum == G_old + G_new.
|
|
//
|
|
// After noteOff (must release newest), the newer voice enters a short release.
|
|
// Render past releaseFrames: newer voice finishes; only the older voice remains.
|
|
// Sum then equals G_old, and activeVoiceCount drops to 1. If the WRONG voice
|
|
// were released, the older would finish and the remaining sum would equal G_new
|
|
// (1.0 vs ~0.504) — the velocities make the error distinguishable.
|
|
const int velOld = 64;
|
|
const int velNew = 127;
|
|
const double gainOld = velOld / 127.0; // ~0.504
|
|
const double gainNew = velNew / 127.0; // 1.0
|
|
|
|
SampleData sd = dcSample(100000, 60);
|
|
sd.play.adsr = flatAdsr();
|
|
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
|
|
Keymap km = Keymap::singleSampleChromatic(sd);
|
|
// A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default
|
|
// flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this
|
|
// note-off-selection test relies on — so we opt this zone back to the linear response.
|
|
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
|
VoiceEngine eng(8, km);
|
|
|
|
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
|
std::size_t second = eng.noteOn(60, velNew); // newer voice, higher gain
|
|
CHECK(first != second);
|
|
CHECK(eng.activeVoiceCount() == 2);
|
|
|
|
// While both are held, combined output equals gainOld + gainNew.
|
|
{
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(approx(out[0], gainOld + gainNew, 1e-4));
|
|
}
|
|
|
|
// Release once — must target the NEWEST voice (second).
|
|
eng.noteOff(60);
|
|
|
|
// Render past the release (releaseFrames == 10): newer voice goes Finished.
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 20);
|
|
|
|
// Newer voice must be done; only the older voice remains.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// Tail frames must equal gainOld (~0.504), NOT gainNew (1.0).
|
|
// If the older voice were released instead, the tail would be ~1.0 here.
|
|
for (std::size_t i = 15; i < out.size(); ++i) {
|
|
CHECK(approx(out[i], gainOld, 1e-4));
|
|
}
|
|
|
|
// A second note-off releases the remaining older voice.
|
|
eng.noteOff(60);
|
|
eng.render(out, 20);
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
}
|
|
|
|
static void testOutOfZoneNoteConsumesNoVoice() {
|
|
Keymap km;
|
|
km.samples.push_back(dcSample(100, 60));
|
|
km.zones.push_back(KeyZone{60, 72, 60, 0});
|
|
VoiceEngine eng(4, km);
|
|
|
|
std::size_t v = eng.noteOn(30, 100); // below the only zone
|
|
CHECK(v == VoiceEngine::kNoVoice);
|
|
CHECK(eng.activeVoiceCount() == 0); // no voice consumed
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Voice stealing at the bound.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testStealsReleasingVoiceFirst() {
|
|
// Long per-zone release so the voice stays active through the release tail. Voice::start reads
|
|
// sample.play.adsr (the engine holds no ADSR), so the long release lives on the SampleData.
|
|
SampleData s = dcSample(100000, 60);
|
|
s.play.adsr = flatAdsr();
|
|
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(2, km);
|
|
|
|
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
|
|
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
|
CHECK(eng.activeVoiceCount() == 2);
|
|
|
|
// Release the NEWER voice (62) — it becomes the only releasing voice.
|
|
eng.noteOff(62);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(eng.activeVoiceCount() == 2); // both still ringing (long release)
|
|
|
|
// A new note with the pool full must steal the RELEASING voice (vB), not the
|
|
// older held voice (vA) — release-first policy.
|
|
std::size_t vC = eng.noteOn(64, 100);
|
|
CHECK(vC == vB);
|
|
CHECK(eng.activeVoiceCount() == 2);
|
|
}
|
|
|
|
static void testStealsOldestWhenNoneReleasing() {
|
|
// Long per-zone release — placed on SampleData.play.adsr per the S12 fix.
|
|
SampleData s = dcSample(100000, 60);
|
|
s.play.adsr = flatAdsr();
|
|
s.play.adsr.releaseFrames = 100000;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(2, km);
|
|
|
|
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
|
|
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
|
|
CHECK(vA != vB);
|
|
|
|
// No voice released; both held. A new note steals the OLDEST (vA).
|
|
std::size_t vC = eng.noteOn(64, 100);
|
|
CHECK(vC == vA);
|
|
CHECK(eng.activeVoiceCount() == 2);
|
|
|
|
// The stolen voice now carries note 64; a note-off on 60 (the stolen-away note)
|
|
// finds nothing to release.
|
|
std::size_t before = eng.activeVoiceCount();
|
|
eng.noteOff(60);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(eng.activeVoiceCount() == before); // 60 no longer exists; no-op
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Loop-point-aware sustain.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testLoopSustainSeamless() {
|
|
// A sample whose [0,20) frames are a distinctive ramp and [20,40) is a flat loop
|
|
// region of value 0.5. Held far past the sample end, the voice must keep emitting
|
|
// the loop region (0.5) rather than going silent.
|
|
SampleData s;
|
|
s.frames.resize(40);
|
|
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) / 20.0f; // attack
|
|
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
|
|
s.rootNote = 60;
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 20;
|
|
s.loop.end = 40;
|
|
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
|
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 200); // 5x the sample length
|
|
|
|
// Voice is still active (looping), not exhausted.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// Frames well past the loop start must sit at the loop body value 0.5.
|
|
for (std::size_t i = 60; i < out.size(); ++i) {
|
|
CHECK(approx(out[i], 0.5, 1e-4));
|
|
}
|
|
}
|
|
|
|
static void testZeroLengthLoopGoesSilent() {
|
|
// A zero-length loop (start == end) is the "no sustain" marker: the note runs off
|
|
// the sample end and the voice goes idle, rather than spinning on an empty span.
|
|
SampleData s = dcSample(50, 60); // 50 frames of 1.0
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 25;
|
|
s.loop.end = 25; // zero length
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 100); // past the 50-frame end
|
|
// After frame ~50 the voice should have gone idle (no loop to sustain it).
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
// Tail frames are silent.
|
|
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
}
|
|
|
|
static void testSingleFrameLoop() {
|
|
// A loop of exactly one frame [start, start+1) — the narrowest valid loop.
|
|
// The path is correct-by-luck (loopLen = 1.0 divides evenly into any integer
|
|
// readPos advance at unity ratio), but Tier-2 tight loops make it load-bearing.
|
|
SampleData s;
|
|
s.frames.resize(10);
|
|
for (int i = 0; i < 10; ++i) s.frames[i] = static_cast<float>(i) * 0.1f;
|
|
s.rootNote = 60;
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 5;
|
|
s.loop.end = 6; // single-frame loop: [5, 6)
|
|
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
|
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 50); // well past the sample end
|
|
|
|
// Voice must still be active — the single-frame loop keeps it alive.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// Every frame from the loop-start onward must be the value of frame 5 (0.5).
|
|
for (std::size_t i = 10; i < out.size(); ++i) {
|
|
CHECK(approx(out[i], 0.5, 1e-4));
|
|
}
|
|
}
|
|
|
|
static void testAbsentLoopGoesSilent() {
|
|
// No loop at all: held note runs off the end and goes idle (same as zero-length).
|
|
SampleData s = dcSample(50, 60);
|
|
// s.loop.hasLoop stays false.
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 100);
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// start point (S11): the voice's initial read position is SampleData::startFrame.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void testStartFrameOffsetsInitialRead() {
|
|
// A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the
|
|
// read position. startFrame = 30 -> the first output frame reads frame 30 (0.30).
|
|
SampleData s;
|
|
s.frames.resize(100);
|
|
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
|
|
s.rootNote = 60;
|
|
s.startFrame = 30;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 3);
|
|
CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0
|
|
CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio
|
|
CHECK(approx(out[2], 0.32, 1e-4));
|
|
}
|
|
|
|
static void testStartFrameZeroIsUnchanged() {
|
|
// startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0.
|
|
SampleData s;
|
|
s.frames.resize(20);
|
|
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
|
|
s.rootNote = 60; // startFrame stays 0
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(approx(out[0], 0.0, 1e-6)); // frame 0
|
|
}
|
|
|
|
static void testStartFrameOutOfRangeClampsToZero() {
|
|
// A start point at/past the sample end degrades to frame 0 (play from the top), never an
|
|
// out-of-bounds read that would start the voice already exhausted.
|
|
SampleData s = dcSample(10, 60); // 10 frames of 1.0
|
|
s.startFrame = 10; // == frameCount: out of range
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 5);
|
|
// Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
CHECK(approx(out[0], 1.0, 1e-4));
|
|
}
|
|
|
|
static void testStartFrameWithLoop() {
|
|
// Start point and loop compose: begin reading mid-sample, then sustain the loop region.
|
|
SampleData s;
|
|
s.frames.resize(40);
|
|
for (int i = 0; i < 40; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
|
|
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
|
|
s.rootNote = 60;
|
|
s.startFrame = 10; // begin at frame 10
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 20;
|
|
s.loop.end = 40;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 200);
|
|
CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10
|
|
CHECK(eng.activeVoiceCount() == 1); // loop sustains it
|
|
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4));
|
|
}
|
|
|
|
static void testStartAfterLoopEndWrapsIntoLoop() {
|
|
// Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount),
|
|
// the voice's initial read head is past the loop end. The wrap-while in renderFrame must
|
|
// pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from
|
|
// somewhere inside the loop rather than running off the sample end silently.
|
|
//
|
|
// Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40).
|
|
// loop body is a constant 0.5 so every frame inside it reads 0.5.
|
|
// After wrap: readPos starts inside [20, 40), first output frame == 0.5.
|
|
// Voice must stay active (loop sustains it) and emit the loop value, NOT go silent.
|
|
SampleData s;
|
|
s.frames.resize(100, 0.0f);
|
|
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
|
|
s.rootNote = 60;
|
|
s.startFrame = 60; // > loop.end (40), < frameCount (100)
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 20;
|
|
s.loop.end = 40;
|
|
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio, full velocity
|
|
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 50);
|
|
|
|
// Voice must still be active — the usable loop keeps it alive indefinitely.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// Every frame after the first wrap must read 0.5 (the loop body). We skip the very
|
|
// first frame because the fractional-position wrap lands somewhere in [20,40) and the
|
|
// exact offset depends on how many loop lengths fit into 60; what matters is that the
|
|
// voice is alive and emitting the loop value, not 0.0 (pre-loop region).
|
|
for (std::size_t i = 5; i < out.size(); ++i) {
|
|
CHECK(approx(out[i], 0.5, 1e-4));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// velocity -> volume.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone is now flat
|
|
// y=1, so EVERY velocity plays at unity — NOT the old linear velocity/127. singleSampleChromatic
|
|
// builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
|
|
static void testVelocityDefaultCurveIsFlatUnity() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0, flat default curve
|
|
for (int vel : {1, 64, 100, 127}) {
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, vel);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(approx(out[0], 1.0, 1e-4)); // flat y=1: any velocity -> unity gain
|
|
}
|
|
}
|
|
|
|
// A LINEAR curve on the zone reproduces the pre-r10 velocity/127 ramp exactly — proving the curve
|
|
// (not a hardcoded map) drives the gain, and that eval is applied at note-on.
|
|
static void testVelocityLinearCurveReproducesRamp() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
|
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
|
{
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out; eng.render(out, 1);
|
|
CHECK(approx(out[0], 1.0, 1e-4));
|
|
}
|
|
{
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 64);
|
|
std::vector<AudioSample> out; eng.render(out, 1);
|
|
CHECK(approx(out[0], 64.0 / 127.0, 1e-4));
|
|
}
|
|
{
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 1);
|
|
std::vector<AudioSample> out; eng.render(out, 1);
|
|
CHECK(approx(out[0], 1.0 / 127.0, 1e-4));
|
|
}
|
|
}
|
|
|
|
// A shaped curve (a single interior knot) drives the gain through eval — a mid velocity reads the
|
|
// curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
|
|
static void testVelocityShapedCurveDrivesGain() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
|
vst::VelocityCurve curve = vst::VelocityCurve::linear();
|
|
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
|
|
km.zones[0].velocityCurve = curve;
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 64);
|
|
std::vector<AudioSample> out; eng.render(out, 1);
|
|
// At exactly velocity 64 the curve passes through the knot -> gain 0.9 (well above the linear
|
|
// 64/127 ~= 0.504), so the rendered DC value is the shaped 0.9.
|
|
CHECK(approx(out[0], 0.9, 1e-4));
|
|
}
|
|
|
|
// Two voices summed: polyphony mixes additively.
|
|
static void testPolyphonyMixesAdditively() {
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(60, 127); // gain 1.0
|
|
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1);
|
|
CHECK(approx(out[0], 2.0, 1e-4)); // both voices sum
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. Stereo channel dimension (S7).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A distinct-per-channel stereo DC sample: L = `l`, R = `r` everywhere. A stereo render
|
|
// must keep them distinct; a mono render (channel 0 only) sees L.
|
|
static SampleData stereoDcSample(std::size_t frames, float l, float r, int rootNote = 60) {
|
|
SampleData s;
|
|
s.frames.assign(frames, l);
|
|
s.framesR.assign(frames, r);
|
|
s.rootNote = rootNote;
|
|
return s;
|
|
}
|
|
|
|
static void testChannelCount() {
|
|
// Mono: framesR empty -> 1 channel. Stereo: matching-length framesR -> 2.
|
|
CHECK(dcSample(10, 60).channelCount() == 1);
|
|
CHECK(stereoDcSample(10, 1.0f, -1.0f).channelCount() == 2);
|
|
// A mismatched framesR length is treated as mono (a bad pair never half-plays).
|
|
SampleData bad = dcSample(10, 60);
|
|
bad.framesR.assign(5, 0.5f); // wrong length
|
|
CHECK(bad.channelCount() == 1);
|
|
}
|
|
|
|
static void testStereoRenderKeepsChannelsDistinct() {
|
|
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
|
|
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
|
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
|
|
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
|
eng.render(left.data(), right.data(), 8);
|
|
for (std::size_t i = 0; i < 8; ++i) {
|
|
CHECK(approx(left[i], 1.0, 1e-4)); // channel 0
|
|
CHECK(approx(right[i], -1.0, 1e-4)); // channel 1 — distinct, NOT a copy of L
|
|
}
|
|
}
|
|
|
|
static void testMonoSamplePlaysDualMonoInStereo() {
|
|
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
|
|
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
|
|
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
|
eng.render(left.data(), right.data(), 8);
|
|
for (std::size_t i = 0; i < 8; ++i) {
|
|
CHECK(approx(left[i], 1.0, 1e-4));
|
|
CHECK(approx(right[i], 1.0, 1e-4)); // R == L (dual-mono), not 0
|
|
}
|
|
}
|
|
|
|
static void testMonoRenderUnchangedByStereoData() {
|
|
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
|
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
|
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
|
|
VoiceEngine engS(1, kmS);
|
|
engS.noteOn(60, 127);
|
|
std::vector<AudioSample> mono;
|
|
engS.render(mono, 8); // the mono overload
|
|
for (std::size_t i = 0; i < 8; ++i) CHECK(approx(mono[i], 0.75, 1e-4)); // == L, ignores R
|
|
}
|
|
|
|
static void testStereoRenderAdvancesLikeMonoRepitch() {
|
|
// The stereo path must advance the read head by the SAME per-frame ratio as the mono path,
|
|
// so repitch is identical. Play a stereo sine (both channels the same signal) an octave up
|
|
// and confirm the observed period halves — the mono repitch assertion, on the stereo path.
|
|
const std::size_t frames = 8000;
|
|
const double cycles = 20.0;
|
|
const double nativePeriod = static_cast<double>(frames) / cycles; // 400
|
|
SampleData s;
|
|
s.frames.resize(frames);
|
|
s.framesR.resize(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
const float v = static_cast<float>(std::sin(2.0 * kPi * cycles *
|
|
static_cast<double>(i) / static_cast<double>(frames)));
|
|
s.frames[i] = v;
|
|
s.framesR[i] = v;
|
|
}
|
|
s.rootNote = 60;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(72, 127); // +1 octave
|
|
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
|
|
eng.render(left.data(), right.data(), frames / 2);
|
|
CHECK(approx(observedPeriodFrames(left), nativePeriod / 2.0, 2.0));
|
|
CHECK(approx(observedPeriodFrames(right), nativePeriod / 2.0, 2.0)); // R repitches identically
|
|
}
|
|
|
|
static void testStereoRenderSumsVoicesPerChannel() {
|
|
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
|
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
|
|
VoiceEngine eng(4, km);
|
|
eng.noteOn(60, 127);
|
|
eng.noteOn(60, 127); // second voice, same note
|
|
std::vector<AudioSample> left(1, 0.f), right(1, 0.f);
|
|
eng.render(left.data(), right.data(), 1);
|
|
CHECK(approx(left[0], 1.0, 1e-4)); // 0.5 + 0.5
|
|
CHECK(approx(right[0], -1.0, 1e-4)); // -0.5 + -0.5
|
|
}
|
|
|
|
static void testStereoRenderNullBufferIsNoOp() {
|
|
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> buf(4, 0.f);
|
|
eng.render(nullptr, buf.data(), 4); // null left -> no-op, no crash
|
|
eng.render(buf.data(), nullptr, 4); // null right -> no-op
|
|
for (float v : buf) CHECK(approx(v, 0.0, 1e-9)); // untouched
|
|
}
|
|
|
|
static void testStereoStartFrameLoopShareOneReadHead() {
|
|
// S7 x S11 compose: a STEREO sample with a startFrame AND a sustain loop must read BOTH
|
|
// channels from the SAME single read head — one offset, one loop wrap, applied to L and R
|
|
// identically (only the sampled value differs). A per-frame L/R ramp that is a fixed offset
|
|
// apart (R = L + 0.5) pins the read position on both channels: if the stereo path ever gave
|
|
// L and R independent heads, the constant L->R offset would break at the start jump or the
|
|
// loop seam.
|
|
SampleData s;
|
|
s.frames.resize(40);
|
|
s.framesR.resize(40);
|
|
for (int i = 0; i < 40; ++i) {
|
|
s.frames[i] = static_cast<float>(i) * 0.01f; // L: 0.00 .. 0.39
|
|
s.framesR[i] = static_cast<float>(i) * 0.01f + 0.5f; // R: L + 0.5, everywhere
|
|
}
|
|
s.rootNote = 60;
|
|
s.startFrame = 10; // begin BOTH channels at frame 10
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 20;
|
|
s.loop.end = 30; // loop [20,30): frames 20..29
|
|
CHECK(s.channelCount() == 2);
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
|
|
|
std::vector<AudioSample> left(200, 0.f), right(200, 0.f);
|
|
eng.render(left.data(), right.data(), 200);
|
|
|
|
// First frame: both channels start at frame 10 (L=0.10, R=0.60) — the shared start offset.
|
|
CHECK(approx(left[0], 0.10, 1e-4));
|
|
CHECK(approx(right[0], 0.60, 1e-4));
|
|
// The loop sustains the voice indefinitely.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// At every rendered frame R - L == 0.5 exactly: both channels read the SAME frame index
|
|
// (one read head) through the start jump and every loop wrap. A per-channel head drift would
|
|
// break this invariant at the seam.
|
|
for (std::size_t i = 0; i < left.size(); ++i) {
|
|
CHECK(approx(right[i] - left[i], 0.5, 1e-4));
|
|
}
|
|
// Once fully inside the loop (start=10 -> reaches loop.start=20 within a handful of unity-ratio
|
|
// frames), every L value sits in the loop band [0.20, 0.30): the shared head is sustaining the
|
|
// loop region on both channels, never running off the sample end.
|
|
for (std::size_t i = 15; i < left.size(); ++i) {
|
|
CHECK(left[i] >= 0.20 - 1e-4 && left[i] < 0.30 + 1e-4);
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// S15 — sampling modes (Gate AHDSR hold stage, Trigger %-length + fades, note-off immunity).
|
|
// ===========================================================================
|
|
|
|
// --- AHDSR hold stage vs a known signal. ---
|
|
static void testAhdsrHoldStageShape() {
|
|
// Gate grows a HOLD stage between Attack and Decay: attack 0->1 (5f), HOLD at 1.0 (8f),
|
|
// decay 1->0.5 (5f), sustain 0.5. Assert the hold plateau is exactly 1.0 for holdFrames.
|
|
AdsrParams p;
|
|
p.attackFrames = 5;
|
|
p.holdFrames = 8;
|
|
p.decayFrames = 5;
|
|
p.sustainLevel = 0.5;
|
|
p.releaseFrames = 5;
|
|
AdsrEnvelope env;
|
|
env.configure(p);
|
|
env.noteOn();
|
|
|
|
for (int i = 0; i < 5; ++i) env.tick(); // consume Attack (ends at 1.0)
|
|
// The next holdFrames ticks must all be exactly 1.0 (the plateau), stage == Hold.
|
|
for (int i = 0; i < 8; ++i) {
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Hold);
|
|
CHECK(approx(env.tick(), 1.0, 1e-9));
|
|
}
|
|
// Then Decay begins, falling from 1.0 toward sustain 0.5.
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Decay);
|
|
double v = env.tick();
|
|
CHECK(v <= 1.0 + 1e-9 && v >= 0.5 - 1e-9);
|
|
}
|
|
|
|
// --- hold == 0 is byte-identical to the pre-S15 ADSR (back-compat regression). ---
|
|
static void testAhdsrHoldZeroEqualsAdsr() {
|
|
// The load-bearing back-compat guarantee: hold=0 reproduces the classic ADSR frame-for-frame.
|
|
// Assert against a HAND-COMPUTED expected sequence (not another envelope — that would be
|
|
// tautological). attack 4, hold 0, decay 4, sustain 0.5. Expected per-tick output:
|
|
// Attack: 0/4, 1/4, 2/4, 3/4 (ticks 0..3, level rising 0 -> 0.75)
|
|
// Decay: 1.0, then 1.0+(0.5-1)*t for t=1/4..3/4 (ticks 4..7: 1.0, 0.875, 0.75, 0.625)
|
|
// Sustain: 0.5 forever (tick 8+)
|
|
AdsrParams p;
|
|
p.attackFrames = 4;
|
|
p.holdFrames = 0; // the degenerate — must NOT insert an extra unity frame
|
|
p.decayFrames = 4;
|
|
p.sustainLevel = 0.5;
|
|
p.releaseFrames = 4;
|
|
AdsrEnvelope env;
|
|
env.configure(p);
|
|
env.noteOn();
|
|
const double expected[] = {0.0, 0.25, 0.5, 0.75, // attack
|
|
1.0, 0.875, 0.75, 0.625, // decay (first sample 1.0 at t=0)
|
|
0.5, 0.5, 0.5}; // sustain
|
|
for (double e : expected) CHECK(approx(env.tick(), e, 1e-9));
|
|
CHECK(env.stage() == AdsrEnvelope::Stage::Sustain); // reached sustain at the SAME tick count
|
|
}
|
|
|
|
// A trigger-mode DC sample (all 1.0) so a rendered voice's output tracks the trigger envelope
|
|
// * velocity directly. `play` sets Trigger mode + params; Varispeed so no shift colours the amp.
|
|
static SampleData triggerSample(std::size_t frames, double lengthFraction,
|
|
std::int64_t fadeIn, std::int64_t fadeOut,
|
|
std::int64_t startFrame = 0) {
|
|
SampleData s = dcSample(frames, 60);
|
|
s.startFrame = startFrame;
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed; // isolate amp shape from pitch
|
|
s.play.trigger.lengthFraction = lengthFraction;
|
|
s.play.trigger.fadeInFrames = fadeIn;
|
|
s.play.trigger.fadeOutFrames = fadeOut;
|
|
return s;
|
|
}
|
|
|
|
// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
|
|
static void testTriggerLengthFractionFrames() {
|
|
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity ratio
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 200);
|
|
// First 100 frames sound (amp>0 for a no-fade trigger = 1.0), then silence + voice freed.
|
|
for (std::size_t i = 0; i < 100; ++i) CHECK(out[i] > 0.5f);
|
|
for (std::size_t i = 100; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
CHECK(eng.activeVoiceCount() == 0); // ran off playEnd
|
|
}
|
|
|
|
// --- Trigger start point: %-length measured from the start offset. ---
|
|
static void testTriggerLengthWithStart() {
|
|
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 200);
|
|
for (std::size_t i = 0; i < 80; ++i) CHECK(out[i] > 0.5f);
|
|
for (std::size_t i = 80; i < 200; ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
}
|
|
|
|
// --- Trigger fade-in / fade-out ramp shape (equal-power default). ---
|
|
static void testTriggerFadeShape() {
|
|
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
|
|
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 120);
|
|
CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0
|
|
// Fade-in monotonic non-decreasing.
|
|
for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4);
|
|
// Unity plateau in the middle.
|
|
for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3));
|
|
// Fade-out monotonic non-increasing over [80,100).
|
|
for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4);
|
|
// Past playEnd = silence.
|
|
for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
}
|
|
|
|
// --- Trigger edge cases: %=0 (immediate free) and fades overlapping (clamped). ---
|
|
static void testTriggerEdgeCases() {
|
|
// %=0: zero play length -> voice frees at once, no sound.
|
|
{
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 50);
|
|
for (float v : out) CHECK(approx(v, 0.0, 1e-6));
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
}
|
|
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
|
|
{
|
|
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 50);
|
|
for (std::size_t i = 0; i < 40; ++i) CHECK(out[i] >= -1e-4 && out[i] <= 1.0 + 1e-4);
|
|
CHECK(eng.activeVoiceCount() == 0);
|
|
}
|
|
// %=100 plays the full post-start span.
|
|
{
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 80);
|
|
for (std::size_t i = 0; i < 60; ++i) CHECK(out[i] > 0.5f);
|
|
for (std::size_t i = 60; i < 80; ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
}
|
|
}
|
|
|
|
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
|
|
static void testTriggerIgnoresNoteOff() {
|
|
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 10);
|
|
eng.noteOff(60); // must be a NO-OP in Trigger
|
|
CHECK(eng.activeVoiceCount() == 1); // still sounding after note-off
|
|
eng.render(out, 200);
|
|
// It still plays its full 100-frame length (frames 10..99 remain > 0 after the note-off).
|
|
for (std::size_t i = 10; i < 100; ++i) CHECK(out[i] > 0.5f);
|
|
for (std::size_t i = 100; i < 210; ++i) CHECK(approx(out[i], 0.0, 1e-6));
|
|
CHECK(eng.activeVoiceCount() == 0); // frees on its own playEnd, not on note-off
|
|
}
|
|
|
|
// ===========================================================================
|
|
// S16 — pitch engine (Preserve duration invariance) + pitch envelope (off = identical).
|
|
// ===========================================================================
|
|
|
|
// Render one note to completion (or `maxFrames`) and return the frame count at which the voice
|
|
// went idle (the audible LENGTH). A Gate note with a short release + a finite sample runs off.
|
|
static std::size_t soundingLength(VoiceEngine& eng, std::size_t maxFrames) {
|
|
std::vector<AudioSample> out;
|
|
std::size_t len = 0;
|
|
for (std::size_t f = 0; f < maxFrames; ++f) {
|
|
eng.render(out, 1);
|
|
if (eng.activeVoiceCount() > 0) len = f + 1;
|
|
else break;
|
|
}
|
|
return len;
|
|
}
|
|
|
|
// A Preserve-engine one-shot Trigger sample: under Preserve, the %-length wall-clock is stable
|
|
// under transpose (the S15xS16 contract). Trigger + Preserve isolates the length measurement from
|
|
// Gate's release tail.
|
|
static SampleData preserveTriggerSample(std::size_t frames, double lengthFraction) {
|
|
SampleData s = dcSample(frames, 60);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
s.play.trigger.lengthFraction = lengthFraction;
|
|
return s;
|
|
}
|
|
|
|
// --- Preserve duration invariance: same note length across +/-12 semitones. ---
|
|
static void testPreserveDurationInvariance() {
|
|
// A Preserve Trigger at 100% length of a 1000-frame sample plays ~1000 output frames
|
|
// regardless of transpose (duration held). Under Varispeed an octave up would halve it.
|
|
const std::size_t frames = 1000;
|
|
const std::size_t window = 512; // pre-size the shifters
|
|
|
|
auto lengthAt = [&](int note) -> std::size_t {
|
|
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
|
eng.noteOn(note, 127);
|
|
return soundingLength(eng, 4000);
|
|
};
|
|
|
|
const std::size_t atRoot = lengthAt(60);
|
|
const std::size_t atUp = lengthAt(72); // +12
|
|
const std::size_t atDown = lengthAt(48); // -12
|
|
// All three within a small tolerance of the source length (Preserve holds duration). The
|
|
// tolerance covers the shifter's fill/latency edge, not a duration scaling (which would be 2x).
|
|
CHECK(atRoot >= frames - 20 && atRoot <= frames + 20);
|
|
CHECK(atUp >= frames - 20 && atUp <= frames + 20);
|
|
CHECK(atDown >= frames - 20 && atDown <= frames + 20);
|
|
// The decisive assertion: the up/down lengths track the root length (NOT halved/doubled).
|
|
CHECK(atUp > frames / 2 + 200); // an octave up did NOT halve the duration (Varispeed would)
|
|
CHECK(atDown < frames * 2 - 200); // an octave down did NOT double it
|
|
}
|
|
|
|
// --- Varispeed still couples duration (the contrast to Preserve — regression on the old default). ---
|
|
static void testVarispeedStillCouplesDuration() {
|
|
// A Varispeed Trigger octave up runs off in ~half the frames (pitch & duration coupled).
|
|
auto lengthAt = [&](int note) -> std::size_t {
|
|
SampleData s = dcSample(1000, 60);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.trigger.lengthFraction = 1.0;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(note, 127);
|
|
return soundingLength(eng, 4000);
|
|
};
|
|
const std::size_t atRoot = lengthAt(60);
|
|
const std::size_t atUp = lengthAt(72);
|
|
CHECK(approx(static_cast<double>(atUp), static_cast<double>(atRoot) / 2.0, 30.0));
|
|
}
|
|
|
|
// --- Pitch envelope OFF == bit-identical to the un-modulated engine (regression). ---
|
|
static void testPitchEnvOffBitIdentical() {
|
|
// Two Varispeed voices, one with a disabled pitch env, one with no pitch env at all. Their
|
|
// rendered output must be BIT-IDENTICAL (pitch-env-off applies zero modulation — the S16
|
|
// "identical to pre-S16" guarantee). Uses a sine so any pitch drift would show as phase drift.
|
|
const std::size_t n = 4000;
|
|
auto renderOne = [&](bool withDisabledEnv) -> std::vector<AudioSample> {
|
|
SampleData s = sineSample(n, 20.0, 60);
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
if (withDisabledEnv) {
|
|
s.play.pitchEnv.enabled = false; // explicitly disabled (offset always 0)
|
|
s.play.pitchEnv.peakSemitones = 12.0; // a depth that WOULD matter if enabled
|
|
s.play.pitchEnv.attackFrames = 0;
|
|
s.play.pitchEnv.decayFrames = 500;
|
|
}
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, n);
|
|
return out;
|
|
};
|
|
const std::vector<AudioSample> a = renderOne(false);
|
|
const std::vector<AudioSample> b = renderOne(true);
|
|
CHECK(a.size() == b.size());
|
|
bool identical = a.size() == b.size();
|
|
for (std::size_t i = 0; i < a.size() && identical; ++i) {
|
|
if (a[i] != b[i]) identical = false;
|
|
}
|
|
CHECK(identical); // disabled pitch env produces the EXACT same samples (no modulation)
|
|
}
|
|
|
|
// --- Pitch envelope ON biases pitch (Varispeed): a positive-peak zero-attack env starts sharp. ---
|
|
static void testPitchEnvOnBendsVarispeed() {
|
|
// Zero attack + positive peak = "start high, drop to base": the note begins transposed UP and
|
|
// settles. Observe the read advancing FASTER at the start (period shorter early) than late.
|
|
const std::size_t n = 8000;
|
|
SampleData s = sineSample(n, 40.0, 60);
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.attackFrames = 0; // start at the peak
|
|
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
|
|
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 4000);
|
|
// Early period (heavily transposed up) should be shorter than the late period (settled).
|
|
std::vector<AudioSample> early(out.begin(), out.begin() + 800);
|
|
std::vector<AudioSample> late(out.begin() + 3200, out.begin() + 4000);
|
|
const double pe = observedPeriodFrames(early);
|
|
const double pl = observedPeriodFrames(late);
|
|
CHECK(pe > 0.0 && pl > 0.0);
|
|
CHECK(pe < pl); // pitch dropped over time (period lengthened) -> the AD env bent the pitch
|
|
}
|
|
|
|
// --- Compose: engine x mode x stereo x loop (a Preserve Gate loop in stereo sounds + sustains). ---
|
|
static void testPreserveGateStereoLoopComposes() {
|
|
// A STEREO sample, GATE mode, PRESERVE engine, with a sustain loop. It must sound on BOTH
|
|
// channels and sustain (the loop keeps the voice alive) — S7 x S15 x S16 all composing.
|
|
SampleData s;
|
|
const std::size_t frames = 400;
|
|
s.frames.resize(frames);
|
|
s.framesR.resize(frames);
|
|
for (std::size_t i = 0; i < frames; ++i) {
|
|
const float v = static_cast<float>(std::sin(2.0 * kPi * 8.0 *
|
|
static_cast<double>(i) / static_cast<double>(frames)));
|
|
s.frames[i] = v;
|
|
s.framesR[i] = v * 0.5f; // R is a distinct (half-amplitude) channel
|
|
}
|
|
s.rootNote = 60;
|
|
s.loop.hasLoop = true;
|
|
s.loop.start = 100;
|
|
s.loop.end = 300;
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
CHECK(s.channelCount() == 2);
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km, 0, 512);
|
|
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
|
|
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
|
|
eng.render(left.data(), right.data(), 2000);
|
|
// The loop sustains the voice well past the sample length (400 frames) -> still active.
|
|
CHECK(eng.activeVoiceCount() == 1);
|
|
// Both channels carry signal (some frame has non-trivial magnitude on each).
|
|
double maxL = 0.0, maxR = 0.0;
|
|
for (std::size_t i = 600; i < 2000; ++i) {
|
|
if (std::fabs(left[i]) > maxL) maxL = std::fabs(left[i]);
|
|
if (std::fabs(right[i]) > maxR) maxR = std::fabs(right[i]);
|
|
}
|
|
CHECK(maxL > 0.05);
|
|
CHECK(maxR > 0.02); // R present (half amplitude), distinct from L -> stereo preserved
|
|
}
|
|
|
|
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
|
// Uses TRANSPOSED notes only: a note at the root demotes to the Varispeed path (FA1 unity
|
|
// bypass) and deliberately does not count toward the cap — see the demotion test below.
|
|
static void testPreserveVoiceCap() {
|
|
SampleData s = dcSample(2000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
// 8 voices total, Preserve cap of 2.
|
|
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
|
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
|
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
|
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
|
CHECK(eng.activeVoiceCount() == 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FA1 — Preserve unity bypass (preview latency) + velocity under the Preserve engine.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A Preserve voice started at UNITY shift (note == effective root, pitch env off) must speak on
|
|
// frame ONE — the FA1 latency fix. Pre-fix, the note ran through the OLA shifter, whose warm()d
|
|
// ring delays onset by a half window (~25 ms at the product 50 ms window): frame 0 was silence.
|
|
// The demoted voice reads the source directly (bit-identical to Varispeed at ratio 1.0).
|
|
static void testPreserveUnityVoiceSpeaksImmediately() {
|
|
SampleData s = dcSample(2000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
|
eng.noteOn(60, 127); // at root: unity shift -> demoted, zero onset delay
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 4);
|
|
CHECK(approx(out[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
|
}
|
|
|
|
// keyTrack 0 collapses EVERY note to unity — an off-root note also demotes and speaks at once.
|
|
static void testPreserveKeyTrackZeroAlsoSpeaksImmediately() {
|
|
SampleData s = dcSample(2000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
km.zones[0].keyTrack = 0.0; // no tracking: all keys play root pitch (unity)
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
|
eng.noteOn(67, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 4);
|
|
CHECK(approx(out[0], 1.0, 1e-6));
|
|
}
|
|
|
|
// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent
|
|
// half-window cost of preserving duration) and the voice reaches full level once the ring fills.
|
|
// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes.
|
|
static void testPreserveTransposedVoiceKeepsOlaPath() {
|
|
SampleData s = dcSample(4000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
|
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1500);
|
|
// Early frames are the shifter's fill (near-silent) — the structural OLA onset.
|
|
double early = 0.0;
|
|
for (std::size_t i = 0; i < 8; ++i) {
|
|
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
|
|
}
|
|
CHECK(early < 0.1);
|
|
// Once the ring is full of the DC source (>= window frames in), output reaches the sample
|
|
// level (Hann taps partition unity, so DC passes at gain 1).
|
|
double late = 0.0;
|
|
for (std::size_t i = 600; i < 1500; ++i) {
|
|
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
|
}
|
|
CHECK(late > 0.9);
|
|
}
|
|
|
|
// A unity-demoted voice does NOT count toward the Preserve cap (it runs no shifter — it costs
|
|
// Varispeed CPU, not OLA CPU), so root-note notes never starve transposed Preserve polyphony.
|
|
static void testPreserveUnityVoiceDoesNotConsumeCap() {
|
|
SampleData s = dcSample(2000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
|
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // unity -> demoted, cap untouched
|
|
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st genuine Preserve voice
|
|
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
|
|
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd genuine Preserve DROPPED
|
|
CHECK(eng.activeVoiceCount() == 3); // demoted + two Preserve
|
|
}
|
|
|
|
// FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the
|
|
// gain under the PRESERVE product-default engine with a CONFIGURED shifter window (every prior
|
|
// velocity test ran the bare Varispeed core). A linear y=x curve at velocity 1 must be
|
|
// near-silent — NOT max volume.
|
|
static void testVelocityCurveAppliesUnderPreserve() {
|
|
auto steadyLevelAt = [&](int vel) -> double {
|
|
SampleData s = dcSample(4000, 60);
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
|
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
|
|
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 1000);
|
|
return static_cast<double>(out[900]); // steady state: ring is fully DC by frame 256
|
|
};
|
|
CHECK(approx(steadyLevelAt(127), 1.0, 0.02));
|
|
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0, 0.02));
|
|
CHECK(steadyLevelAt(1) < 0.02); // y=x at velocity 1: near-silent, the Daniel repro case
|
|
}
|
|
|
|
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
|
|
//
|
|
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
|
// keymap build); the engine holds no instrument-wide ADSR. These two tests assert that path.
|
|
|
|
// The zone's attackFrames drives the envelope ramp. Strategy: put an explicit 10-frame attack on
|
|
// the SampleData.play.adsr. If Voice::start reads the zone ADSR, the DC-1 output will be 0 at frame
|
|
// 0 and 1.0 after the 10-frame ramp; a voice that ignored the zone ADSR (instant) would already be
|
|
// 1.0 at frame 0. This is the load-bearing proof.
|
|
static void testPerZoneAdsrReachesVoiceEnvelope() {
|
|
SampleData s = dcSample(500, 60);
|
|
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
|
|
s.play.adsr.attackFrames = 10;
|
|
s.play.adsr.holdFrames = 0;
|
|
s.play.adsr.decayFrames = 0;
|
|
s.play.adsr.sustainLevel = 1.0;
|
|
s.play.adsr.releaseFrames = 0;
|
|
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 20);
|
|
// Frame 0: attack start, envelope near 0. A voice ignoring the zone ADSR would read 1.0 here.
|
|
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
|
|
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
|
|
CHECK(out[9] < 1.0 - 1e-9);
|
|
// Frame 10+: attack complete, sustain at 1.0.
|
|
CHECK(approx(out[10], 1.0, 1e-9));
|
|
CHECK(approx(out[19], 1.0, 1e-9));
|
|
}
|
|
|
|
// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path.
|
|
// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must
|
|
// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the
|
|
// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before.
|
|
static void testZeroAdsrIsInstantSustain() {
|
|
SampleData s = dcSample(20, 60);
|
|
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
|
|
s.play.adsr = AdsrParams{};
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
|
VoiceEngine eng(1, km);
|
|
eng.noteOn(60, 127);
|
|
std::vector<AudioSample> out;
|
|
eng.render(out, 5);
|
|
// All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level.
|
|
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
|
|
}
|
|
|
|
int main() {
|
|
testChromaticSingleRoot();
|
|
testZonedRangesBoundaries();
|
|
testFirstMatchOnOverlap();
|
|
testPitchRatioMath();
|
|
testKeyTrackedRatioMath();
|
|
testRepitchObservedPeriod();
|
|
testKeyTrackVarispeedObservedPeriod();
|
|
testKeyTrackPreserveShiftCollapsesAtZero();
|
|
testAdsrShape();
|
|
testAdsrReleaseBeforeSustain();
|
|
testAdsrZeroAttackDecay();
|
|
testPolyphonicAllocation();
|
|
testNoteOffReleasesNewestSameNote();
|
|
testOutOfZoneNoteConsumesNoVoice();
|
|
testStealsReleasingVoiceFirst();
|
|
testStealsOldestWhenNoneReleasing();
|
|
testLoopSustainSeamless();
|
|
testZeroLengthLoopGoesSilent();
|
|
testSingleFrameLoop();
|
|
testAbsentLoopGoesSilent();
|
|
testStartFrameOffsetsInitialRead();
|
|
testStartFrameZeroIsUnchanged();
|
|
testStartFrameOutOfRangeClampsToZero();
|
|
testStartFrameWithLoop();
|
|
testStartAfterLoopEndWrapsIntoLoop();
|
|
testVelocityDefaultCurveIsFlatUnity();
|
|
testVelocityLinearCurveReproducesRamp();
|
|
testVelocityShapedCurveDrivesGain();
|
|
testPolyphonyMixesAdditively();
|
|
testChannelCount();
|
|
testStereoRenderKeepsChannelsDistinct();
|
|
testMonoSamplePlaysDualMonoInStereo();
|
|
testMonoRenderUnchangedByStereoData();
|
|
testStereoRenderAdvancesLikeMonoRepitch();
|
|
testStereoRenderSumsVoicesPerChannel();
|
|
testStereoRenderNullBufferIsNoOp();
|
|
testStereoStartFrameLoopShareOneReadHead();
|
|
|
|
// S15 — sampling modes.
|
|
testAhdsrHoldStageShape();
|
|
testAhdsrHoldZeroEqualsAdsr();
|
|
testTriggerLengthFractionFrames();
|
|
testTriggerLengthWithStart();
|
|
testTriggerFadeShape();
|
|
testTriggerEdgeCases();
|
|
testTriggerIgnoresNoteOff();
|
|
|
|
// S16 — pitch engine + pitch envelope.
|
|
testPreserveDurationInvariance();
|
|
testVarispeedStillCouplesDuration();
|
|
testPitchEnvOffBitIdentical();
|
|
testPitchEnvOnBendsVarispeed();
|
|
testPreserveGateStereoLoopComposes();
|
|
testPreserveVoiceCap();
|
|
|
|
// FA1 — Preserve unity bypass (preview latency) + velocity under Preserve.
|
|
testPreserveUnityVoiceSpeaksImmediately();
|
|
testPreserveKeyTrackZeroAlsoSpeaksImmediately();
|
|
testPreserveTransposedVoiceKeepsOlaPath();
|
|
testPreserveUnityVoiceDoesNotConsumeCap();
|
|
testVelocityCurveAppliesUnderPreserve();
|
|
|
|
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
|
testPerZoneAdsrReachesVoiceEnvelope();
|
|
testZeroAdsrIsInstantSustain();
|
|
|
|
if (g_fail == 0) {
|
|
std::printf("all sampler_core tests passed\n");
|
|
return 0;
|
|
}
|
|
std::printf("%d sampler_core check(s) failed\n", g_fail);
|
|
return 1;
|
|
}
|