S15/S16: Gate(AHDSR)/Trigger play modes + Varispeed/Preserve pitch engines + AD pitch envelope
Per-zone play params on SampleData; hand-rolled pure pitch_shift OLA for Preserve (WDL drags windows.h); zone-payload v3 tail; RT-safe pre-warmed shifters + Preserve voice cap.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// Standalone tests for reasampler::PitchShifter — the S16 Preserve-engine DSP core. No VST3,
|
||||
// no REAPER, no vendor, no test framework. The compile-time proof it does NOT drag the WDL
|
||||
// <windows.h> chain is the CMake target linking only pitch_shift (+ peaks).
|
||||
//
|
||||
// Covers (PLAN.md S16 / CONTEXT.md §Pitch engine modes — Preserve):
|
||||
// 1. duration invariance — N inputs yield N outputs at every shift ratio (the load-bearing
|
||||
// Preserve property: a transposed render is the SAME frame length as the un-transposed one).
|
||||
// 2. unity pass-through fidelity — ratio 1.0 reproduces the input closely (a shifter at unity
|
||||
// must not mangle the signal).
|
||||
// 3. transpose direction — an octave-up shift raises the observed pitch (period shortens), an
|
||||
// octave-down lowers it (period lengthens), measured on a synthesized sine.
|
||||
// 4. RT discipline surrogate — after configure()+warm() (the off-thread setup), a long
|
||||
// process() run never resizes the ring (checked via window() constancy) and never returns
|
||||
// NaN/inf; pass-through (unconfigured) returns input verbatim.
|
||||
|
||||
#include "../src/vst/pitch_shift.h"
|
||||
|
||||
#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 sine of `cycles` periods over `frames` frames.
|
||||
static std::vector<AudioSample> sine(std::size_t frames, double cycles) {
|
||||
std::vector<AudioSample> s(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) {
|
||||
s[i] = static_cast<float>(std::sin(2.0 * kPi * cycles *
|
||||
static_cast<double>(i) / static_cast<double>(frames)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Average spacing between positive-going zero crossings (the observed period).
|
||||
static double observedPeriod(const std::vector<AudioSample>& out, std::size_t from) {
|
||||
std::vector<std::size_t> up;
|
||||
for (std::size_t i = from + 1; i < out.size(); ++i) {
|
||||
if (out[i - 1] <= 0.0f && out[i] > 0.0f) up.push_back(i);
|
||||
}
|
||||
if (up.size() < 2) return 0.0;
|
||||
double sum = 0.0;
|
||||
for (std::size_t i = 1; i < up.size(); ++i) sum += static_cast<double>(up[i] - up[i - 1]);
|
||||
return sum / static_cast<double>(up.size() - 1);
|
||||
}
|
||||
|
||||
// --- 1. Duration invariance across shift ratios. ---
|
||||
static void testDurationInvariance() {
|
||||
// The core Preserve property: whatever the shift ratio, one input frame yields one output
|
||||
// frame. So a shifter fed N frames produces exactly N frames — a transposed render is the
|
||||
// same length as an un-transposed one (unlike Varispeed, where an octave up halves length).
|
||||
const std::size_t n = 4000;
|
||||
const std::vector<AudioSample> in = sine(n, 40.0);
|
||||
const double ratios[] = {0.5, 1.0, 2.0, std::pow(2.0, 7.0 / 12.0)};
|
||||
for (double r : ratios) {
|
||||
PitchShifter ps;
|
||||
ps.configure(2205); // ~50 ms @ 44.1k
|
||||
ps.warm();
|
||||
ps.setShiftRatio(r);
|
||||
std::size_t produced = 0;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const AudioSample o = ps.process(in[i]);
|
||||
(void)o;
|
||||
++produced; // exactly one output per input, unconditionally.
|
||||
}
|
||||
CHECK(produced == n); // duration held at every ratio.
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Unity pass-through fidelity. ---
|
||||
static void testUnityRoughlyReproduces() {
|
||||
// At ratio 1.0 the shifter should reproduce the input's PITCH faithfully (the OLA taps run
|
||||
// in lockstep with the writer). Amplitude/phase warble is allowed (basic OLA), but the
|
||||
// observed period must match the source period within a small tolerance past the warm-up.
|
||||
const std::size_t n = 8000;
|
||||
const double cycles = 40.0;
|
||||
const double nativePeriod = static_cast<double>(n) / cycles; // 200
|
||||
const std::vector<AudioSample> in = sine(n, cycles);
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
// Measure past the initial half-window latency region.
|
||||
const double p = observedPeriod(out, 3000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod, nativePeriod * 0.10)); // within 10% of source period
|
||||
}
|
||||
|
||||
// --- 3. Transpose direction: up shortens the period, down lengthens it. ---
|
||||
static void testTransposeDirection() {
|
||||
const std::size_t n = 12000;
|
||||
const double cycles = 60.0;
|
||||
const double nativePeriod = static_cast<double>(n) / cycles; // 200
|
||||
const std::vector<AudioSample> in = sine(n, cycles);
|
||||
|
||||
// Octave up: output period ~ half the source period (higher pitch).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(2.0);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
const double p = observedPeriod(out, 4000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod / 2.0, nativePeriod * 0.15)); // period halves
|
||||
}
|
||||
// Octave down: output period ~ double the source period (lower pitch).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(2205);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(0.5);
|
||||
std::vector<AudioSample> out(n);
|
||||
for (std::size_t i = 0; i < n; ++i) out[i] = ps.process(in[i]);
|
||||
const double p = observedPeriod(out, 4000);
|
||||
CHECK(p > 0.0);
|
||||
CHECK(approx(p, nativePeriod * 2.0, nativePeriod * 0.30)); // period doubles
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. RT discipline surrogate + pass-through. ---
|
||||
static void testRtDisciplineAndPassthrough() {
|
||||
// Unconfigured shifter passes input through verbatim (a Varispeed voice never allocates one).
|
||||
{
|
||||
PitchShifter ps;
|
||||
CHECK(!ps.configured());
|
||||
CHECK(ps.process(0.37f) == 0.37f); // exact pass-through
|
||||
CHECK(ps.process(-0.9f) == -0.9f);
|
||||
}
|
||||
// Configured: the window is fixed at configure() and never changes across a long run (no
|
||||
// per-frame Resize), and no output is NaN/inf (numerically well-behaved OLA).
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(1024);
|
||||
ps.warm();
|
||||
const std::int64_t w = ps.window();
|
||||
CHECK(w == 1024);
|
||||
ps.setShiftRatio(std::pow(2.0, 5.0 / 12.0));
|
||||
const std::vector<AudioSample> in = sine(20000, 100.0);
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
const AudioSample o = ps.process(in[i]);
|
||||
CHECK(std::isfinite(o));
|
||||
}
|
||||
CHECK(ps.window() == w); // window unchanged -> ring never resized mid-run
|
||||
}
|
||||
// A non-positive shift ratio is ignored (keeps the last valid ratio) — never stalls/reverses.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(512);
|
||||
ps.warm();
|
||||
ps.setShiftRatio(1.0);
|
||||
ps.setShiftRatio(-2.0); // ignored
|
||||
ps.setShiftRatio(0.0); // ignored
|
||||
for (int i = 0; i < 2000; ++i) CHECK(std::isfinite(ps.process(0.5f)));
|
||||
}
|
||||
// Degenerate window (<= 1) stays pass-through even after configure.
|
||||
{
|
||||
PitchShifter ps;
|
||||
ps.configure(1);
|
||||
CHECK(!ps.configured());
|
||||
CHECK(ps.process(0.25f) == 0.25f);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDurationInvariance();
|
||||
testUnityRoughlyReproduces();
|
||||
testTransposeDirection();
|
||||
testRtDisciplineAndPassthrough();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all pitch_shift tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("%d pitch_shift check(s) failed\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -979,6 +979,113 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
|
||||
!back.map.zones[1].startPoint.has_value());
|
||||
}
|
||||
|
||||
// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift -------------
|
||||
|
||||
static void testPlayParamsRoundTrip() {
|
||||
// A zone carrying explicit S15/S16 play params (Trigger mode, hold, fades, Varispeed engine,
|
||||
// pitch env on) must round-trip ALL fields losslessly through the payload-v3 tail.
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("lead", 20, 100, /*override=*/55);
|
||||
z.play.playMode = PlayMode::Trigger;
|
||||
z.play.adsr.holdFrames = 1234;
|
||||
z.play.trigger.lengthFraction = 0.375;
|
||||
z.play.trigger.fadeInFrames = 64;
|
||||
z.play.trigger.fadeOutFrames = 128;
|
||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = true;
|
||||
z.play.pitchEnv.attackFrames = 10;
|
||||
z.play.pitchEnv.decayFrames = 500;
|
||||
z.play.pitchEnv.peakSemitones = -7.5;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
const ZonePlayParams& p = back.zones[0].play;
|
||||
CHECK(p.playMode == PlayMode::Trigger);
|
||||
CHECK(p.adsr.holdFrames == 1234);
|
||||
CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip
|
||||
CHECK(p.trigger.fadeInFrames == 64);
|
||||
CHECK(p.trigger.fadeOutFrames == 128);
|
||||
CHECK(p.pitchEngine == PitchEngine::Varispeed);
|
||||
CHECK(p.pitchEnv.enabled == true);
|
||||
CHECK(p.pitchEnv.attackFrames == 10);
|
||||
CHECK(p.pitchEnv.decayFrames == 500);
|
||||
CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip
|
||||
}
|
||||
|
||||
static void testPlayParamsComposeWithLoopStart() {
|
||||
// S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together.
|
||||
PerformanceMap m;
|
||||
PerformanceZone z = zone("pad", 0, 60);
|
||||
SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222;
|
||||
z.loopOverride = lp;
|
||||
z.startPoint = 333;
|
||||
z.play.playMode = PlayMode::Gate;
|
||||
z.play.adsr.holdFrames = 999;
|
||||
z.play.pitchEngine = PitchEngine::Preserve;
|
||||
m.zones.push_back(z);
|
||||
const PerformanceMap back = deserializePerformance(serializePerformance(m));
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
CHECK(back.zones[0].loopOverride.has_value() &&
|
||||
back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222);
|
||||
CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333);
|
||||
CHECK(back.zones[0].play.adsr.holdFrames == 999);
|
||||
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||
}
|
||||
|
||||
static void testPlayParamsV2BackCompatLiftsToDefaults() {
|
||||
// A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail)
|
||||
// lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the
|
||||
// deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly.
|
||||
std::vector<std::uint8_t> b;
|
||||
auto u32 = [&](std::uint32_t v) {
|
||||
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
|
||||
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
|
||||
};
|
||||
u32(kPerformanceStateVersion); // envelope version (2)
|
||||
u32(kZonesFormatMarker); // marker -> a versioned payload
|
||||
u32(2); // PAYLOAD VERSION 2 (S11, no play tail)
|
||||
u32(1); // zone count 1
|
||||
const std::string id = "old";
|
||||
u32(static_cast<std::uint32_t>(id.size()));
|
||||
b.insert(b.end(), id.begin(), id.end());
|
||||
u32(5); // lowNote
|
||||
u32(80); // highNote
|
||||
b.push_back(0); // hasRootOverride = 0
|
||||
b.push_back(0); // hasLoopOverride = 0
|
||||
b.push_back(0); // hasStartPoint = 0 (record ends here in v2)
|
||||
const PerformanceMap back = deserializePerformance(b);
|
||||
CHECK(back.zones.size() == 1);
|
||||
if (back.zones.size() != 1) return;
|
||||
CHECK(back.zones[0].sampleId == "old");
|
||||
// Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off.
|
||||
CHECK(back.zones[0].play.playMode == PlayMode::Gate);
|
||||
CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve
|
||||
CHECK(back.zones[0].play.pitchEnv.enabled == false);
|
||||
CHECK(back.zones[0].play.adsr.holdFrames == 0);
|
||||
}
|
||||
|
||||
static void testPlayParamsThroughComponentEnvelope() {
|
||||
// The play params round-trip through the v4 COMPONENT envelope too (the composition property:
|
||||
// the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them).
|
||||
ComponentState s;
|
||||
s.selectionId = "pick";
|
||||
s.channelMode = ChannelMode::Stereo;
|
||||
PerformanceZone z = zone("z", 0, 127);
|
||||
z.play.playMode = PlayMode::Trigger;
|
||||
z.play.trigger.lengthFraction = 0.9;
|
||||
z.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.map.zones.push_back(z);
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s));
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
CHECK(back.map.zones.size() == 1);
|
||||
if (back.map.zones.size() != 1) return;
|
||||
CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger);
|
||||
CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9);
|
||||
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSelectByIdHit();
|
||||
testSelectEmptyIdIsSilence();
|
||||
@@ -1026,6 +1133,10 @@ int main() {
|
||||
testPerformanceStateV1BackCompat();
|
||||
testPerformanceStateGarbage();
|
||||
testPerformanceStateNegativeNotesRoundTrip();
|
||||
testPlayParamsRoundTrip();
|
||||
testPlayParamsComposeWithLoopStart();
|
||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||
testPlayParamsThroughComponentEnvelope();
|
||||
testComponentStateRoundTrip();
|
||||
testComponentStateLoopStartRoundTrip();
|
||||
testComponentStateSelectionOnlyNoZones();
|
||||
|
||||
@@ -829,6 +829,354 @@ static void testStereoStartFrameLoopShareOneReadHead() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr(), /*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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr());
|
||||
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, flatAdsr(), 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. ---
|
||||
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, flatAdsr(), /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testChromaticSingleRoot();
|
||||
testZonedRangesBoundaries();
|
||||
@@ -863,6 +1211,23 @@ int main() {
|
||||
testStereoRenderNullBufferIsNoOp();
|
||||
testStereoStartFrameLoopShareOneReadHead();
|
||||
|
||||
// S15 — sampling modes.
|
||||
testAhdsrHoldStageShape();
|
||||
testAhdsrHoldZeroEqualsAdsr();
|
||||
testTriggerLengthFractionFrames();
|
||||
testTriggerLengthWithStart();
|
||||
testTriggerFadeShape();
|
||||
testTriggerEdgeCases();
|
||||
testTriggerIgnoresNoteOff();
|
||||
|
||||
// S16 — pitch engine + pitch envelope.
|
||||
testPreserveDurationInvariance();
|
||||
testVarispeedStillCouplesDuration();
|
||||
testPitchEnvOffBitIdentical();
|
||||
testPitchEnvOnBendsVarispeed();
|
||||
testPreserveGateStereoLoopComposes();
|
||||
testPreserveVoiceCap();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all sampler_core tests passed\n");
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user