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:
@@ -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