// 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 #include #include 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(std::sin(2.0 * kPi * cycles * static_cast(i) / static_cast(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 } // 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& out) { std::vector 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(upCrossings[i] - upCrossings[i - 1]); } return sum / static_cast(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(frames) / cycles; // 400 // Unity: played at root, observed period ~= native. { Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60)); VoiceEngine eng(4, km, flatAdsr()); eng.noteOn(60, 127); std::vector 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, flatAdsr()); eng.noteOn(72, 127); std::vector 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, flatAdsr()); eng.noteOn(48, 127); std::vector out; eng.render(out, frames); double p = observedPeriodFrames(out); CHECK(approx(p, nativePeriod * 2.0, 4.0)); } } // --------------------------------------------------------------------------- // 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, flatAdsr()); // 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 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 Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); AdsrParams a = flatAdsr(); a.releaseFrames = 10; // short but non-zero so voice stays active through release VoiceEngine eng(8, km, a); 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 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 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, flatAdsr()); 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() { Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); AdsrParams a = flatAdsr(); a.releaseFrames = 100000; // long release so a released voice stays "active". VoiceEngine eng(2, km, a); 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 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() { Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60)); AdsrParams a = flatAdsr(); a.releaseFrames = 100000; VoiceEngine eng(2, km, a); 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 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(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, flatAdsr()); eng.noteOn(60, 127); // unity ratio, full velocity std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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(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, flatAdsr()); eng.noteOn(60, 127); // unity ratio, full velocity std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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(i) * 0.01f; s.rootNote = 60; s.startFrame = 30; Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(1, km, flatAdsr()); eng.noteOn(60, 127); // unity ratio, full velocity, flat gain std::vector 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(i) * 0.05f; s.rootNote = 60; // startFrame stays 0 Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(1, km, flatAdsr()); eng.noteOn(60, 127); std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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(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, flatAdsr()); eng.noteOn(60, 127); std::vector 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, flatAdsr()); eng.noteOn(60, 127); // unity ratio, full velocity std::vector 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. // --------------------------------------------------------------------------- static void testVelocityToVolume() { Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 // Full velocity -> full gain; half velocity -> ~half gain (flat envelope so the // rendered value is exactly velocity/127 on a DC-1 sample). { VoiceEngine eng(1, km, flatAdsr()); eng.noteOn(60, 127); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 1.0, 1e-4)); } { VoiceEngine eng(1, km, flatAdsr()); eng.noteOn(60, 64); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 64.0 / 127.0, 1e-4)); } { VoiceEngine eng(1, km, flatAdsr()); eng.noteOn(60, 1); std::vector out; eng.render(out, 1); CHECK(approx(out[0], 1.0 / 127.0, 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, flatAdsr()); eng.noteOn(60, 127); // gain 1.0 eng.noteOn(60, 127); // gain 1.0 (second voice, same note) std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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, flatAdsr()); engS.noteOn(60, 127); std::vector 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(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(std::sin(2.0 * kPi * cycles * static_cast(i) / static_cast(frames))); s.frames[i] = v; s.framesR[i] = v; } s.rootNote = 60; Keymap km = Keymap::singleSampleChromatic(std::move(s)); VoiceEngine eng(4, km, flatAdsr()); eng.noteOn(72, 127); // +1 octave std::vector 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, flatAdsr()); eng.noteOn(60, 127); eng.noteOn(60, 127); // second voice, same note std::vector 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, flatAdsr()); eng.noteOn(60, 127); std::vector 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 } int main() { testChromaticSingleRoot(); testZonedRangesBoundaries(); testFirstMatchOnOverlap(); testPitchRatioMath(); testRepitchObservedPeriod(); testAdsrShape(); testAdsrReleaseBeforeSustain(); testAdsrZeroAttackDecay(); testPolyphonicAllocation(); testNoteOffReleasesNewestSameNote(); testOutOfZoneNoteConsumesNoVoice(); testStealsReleasingVoiceFirst(); testStealsOldestWhenNoneReleasing(); testLoopSustainSeamless(); testZeroLengthLoopGoesSilent(); testSingleFrameLoop(); testAbsentLoopGoesSilent(); testStartFrameOffsetsInitialRead(); testStartFrameZeroIsUnchanged(); testStartFrameOutOfRangeClampsToZero(); testStartFrameWithLoop(); testStartAfterLoopEndWrapsIntoLoop(); testVelocityToVolume(); testPolyphonyMixesAdditively(); testChannelCount(); testStereoRenderKeepsChannelsDistinct(); testMonoSamplePlaysDualMonoInStereo(); testMonoRenderUnchangedByStereoData(); testStereoRenderAdvancesLikeMonoRepitch(); testStereoRenderSumsVoicesPerChannel(); testStereoRenderNullBufferIsNoOp(); 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; }