Phase S voices: user-set polyphony, mono stack (retrig|legato), isolated preview card, idle-drain retirement; unity bypass preview-only (v7)
This commit is contained in:
+369
-31
@@ -1292,8 +1292,8 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
// Since the Phase S re-scope EVERY engine Preserve voice (root included) runs the shifter and
|
||||
// counts toward the cap — the unity demotion is preview-card-only (see the Phase S section).
|
||||
static void testPreserveVoiceCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||
@@ -1307,35 +1307,78 @@ static void testPreserveVoiceCap() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FA1 — Preserve unity bypass (preview latency) + velocity under the Preserve engine.
|
||||
// FA1 (re-scoped by Phase S) — the Preserve unity-Varispeed bypass now belongs to the PREVIEW
|
||||
// CARD ONLY. The MIDI engine keeps the shifter at EVERY Preserve note so a chromatic line has
|
||||
// one uniform onset (the FA1-review ~25 ms root-note timing-step finding); the card — always
|
||||
// fired at the effective root, latency-critical, with no line to be uneven against — opts in
|
||||
// and speaks on frame one.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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);
|
||||
// The ENGINE'S root-note Preserve voice now keeps the OLA path: frame 0 is the shifter's fill
|
||||
// (near-silent), full level once the ring fills — the SAME onset as its transposed neighbors.
|
||||
// Pre-re-scope this voice was demoted and spoke at 1.0 on frame 0.
|
||||
static void testPreserveUnityEngineVoiceKeepsUniformOnset() {
|
||||
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(60, 127); // at root: unity shift -> demoted, zero onset delay
|
||||
eng.noteOn(60, 127); // at root: unity shift — NO demotion in the MIDI engine
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 4);
|
||||
CHECK(approx(out[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
||||
eng.render(out, 1500);
|
||||
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); // shifter onset, exactly like a transposed note
|
||||
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); // and the ring fills to full level
|
||||
}
|
||||
|
||||
// keyTrack 0 collapses EVERY note to unity — an off-root note also demotes and speaks at once.
|
||||
static void testPreserveKeyTrackZeroAlsoSpeaksImmediately() {
|
||||
// The PREVIEW CARD at unity speaks on frame ONE — the FA1 latency fix, now scoped to the card.
|
||||
static void testPreviewCardUnitySpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(60, 127); // at root: unity shift -> demoted inside the card, zero onset delay
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 1.0, 1e-6)); // the DC sample, on the very first frame
|
||||
}
|
||||
|
||||
// keyTrack 0 collapses EVERY note to unity — an off-root preview also demotes, speaks at once.
|
||||
static void testPreviewCardKeyTrackZeroAlsoSpeaksImmediately() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
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));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(67, 127);
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 1.0, 1e-6));
|
||||
}
|
||||
|
||||
// A TRANSPOSED preview keeps the genuine OLA path — the card's demotion is unity-ONLY.
|
||||
static void testPreviewCardTransposedKeepsShifter() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
PreviewCard card(km, /*preserveWindowFrames=*/512);
|
||||
card.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> buf(8, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(buf[i])));
|
||||
}
|
||||
CHECK(early < 0.1); // shifter fill — duration preservation kept for off-root previews
|
||||
}
|
||||
|
||||
// A TRANSPOSED Preserve note keeps the genuine OLA path: onset is shifter-delayed (the inherent
|
||||
@@ -1364,18 +1407,17 @@ static void testPreserveTransposedVoiceKeepsOlaPath() {
|
||||
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() {
|
||||
// Phase S re-scope consequence: a ROOT-note engine Preserve voice keeps its shifter, so it
|
||||
// COUNTS toward the Preserve cap like any other (pre-re-scope it was demoted and exempt).
|
||||
static void testPreserveUnityVoiceCountsTowardCap() {
|
||||
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
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
|
||||
CHECK(eng.noteOn(64, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
}
|
||||
|
||||
// FA1 bug 3a regression, in the DAW's ACTUAL configuration: the velocity curve must drive the
|
||||
@@ -1449,6 +1491,283 @@ static void testZeroAdsrIsInstantSustain() {
|
||||
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase S — parameterized voice count, MONO mode (last-note held stack, Retrigger/Legato),
|
||||
// and the isolated PREVIEW CARD.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A DC sample at `level` with a flat (instant, fully-open) envelope — rendered output equals
|
||||
// level * velocity gain, so WHICH sample is sounding is directly observable in the mix.
|
||||
static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, level);
|
||||
s.rootNote = rootNote;
|
||||
s.play.adsr = flatAdsr();
|
||||
return s;
|
||||
}
|
||||
|
||||
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
|
||||
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
|
||||
// notes [60,80] root 70 -> 0.75.
|
||||
static Keymap twoLevelKeymap() {
|
||||
Keymap km;
|
||||
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
|
||||
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
|
||||
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
|
||||
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
|
||||
km.zones.push_back(a);
|
||||
km.zones.push_back(b);
|
||||
return km;
|
||||
}
|
||||
|
||||
// The rendered value on the next frame — one-frame probe of "what is sounding right now".
|
||||
static double probeFrame(VoiceEngine& eng) {
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
return static_cast<double>(out[0]);
|
||||
}
|
||||
|
||||
// MONO last-note priority: a new note TAKES the single voice; releasing the top note falls
|
||||
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
|
||||
// ONE voice regardless of the pool size.
|
||||
static void testMonoLastNotePriorityAndFallback() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
|
||||
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70); // top released -> FALLBACK to still-held 50
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
eng.noteOff(50); // last finger up -> gate off (release 0 = instant)
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
|
||||
// note also leaves the stack, so the final note-off truly empties it.
|
||||
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127);
|
||||
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
|
||||
eng.noteOff(50); // releasing the buried note: inaudible
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
}
|
||||
|
||||
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
|
||||
// beneath becomes the fallback.
|
||||
static void testMonoRepressHeldNoteMovesToTop() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127);
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
eng.noteOff(50); // falls back to 70 (now the most recent held)
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
}
|
||||
|
||||
// A RETRIGGER fallback re-strikes the fallen-back-to note at ITS ORIGINAL velocity (kept per
|
||||
// held note on the stack), not the departing note's.
|
||||
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = vst::VelocityCurve::linear(); // gain = velocity/127
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(60, 32); // soft first note
|
||||
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||
eng.noteOn(64, 127); // loud takeover
|
||||
CHECK(approx(probeFrame(eng), 1.0, 1e-6));
|
||||
eng.noteOff(64); // fallback re-strikes 60 at ITS velocity (32)
|
||||
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||
}
|
||||
|
||||
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
|
||||
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
|
||||
static void testMonoOutOfZoneNeverJoinsStack() {
|
||||
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
|
||||
eng.noteOff(20); // inert
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
}
|
||||
|
||||
// RETRIGGER restarts the amplitude envelope on a mono takeover: mid-attack level drops back
|
||||
// to the ramp's origin when the new note takes the voice.
|
||||
static void testMonoRetriggerRestartsEnvelope() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50); // mid-attack: level ~0.49 at frame 49
|
||||
CHECK(approx(out[49], 0.49, 1e-6));
|
||||
eng.noteOn(62, 127); // takeover: envelope RESTARTS
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // back at the attack origin
|
||||
}
|
||||
|
||||
// LEGATO keeps the envelope running through a same-sample takeover: pitch moves, NO re-attack.
|
||||
static void testMonoLegatoContinuesEnvelope() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50);
|
||||
CHECK(approx(out[49], 0.49, 1e-6));
|
||||
eng.noteOn(62, 127); // legato takeover: envelope KEEPS running
|
||||
CHECK(approx(probeFrame(eng), 0.50, 1e-6)); // frame 50 of the SAME attack ramp
|
||||
}
|
||||
|
||||
// LEGATO retunes without restarting the read head, and the velocity gain stays the FIRST
|
||||
// note's (a legato phrase is one gesture, one strike). Observed on a ramp sample: values
|
||||
// continue from the current read position at the NEW pitch ratio; a soft second strike does
|
||||
// not duck the level.
|
||||
static void testMonoLegatoRetunesWithoutReadRestart() {
|
||||
SampleData s;
|
||||
s.frames.resize(200000);
|
||||
for (std::size_t i = 0; i < s.frames.size(); ++i) {
|
||||
s.frames[i] = static_cast<float>(i); // ramp: output value == read position
|
||||
}
|
||||
s.rootNote = 60;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10);
|
||||
CHECK(approx(out[9], 9.0, 1e-4));
|
||||
eng.noteOn(72, 1); // legato to +1 octave at a WHISPER velocity
|
||||
CHECK(approx(probeFrame(eng), 10.0, 1e-3)); // read CONTINUES at 10 — no restart, gain kept
|
||||
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
|
||||
}
|
||||
|
||||
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
|
||||
// sample restarts the voice (one read head cannot glide between two PCM streams).
|
||||
static void testMonoLegatoCrossSampleRestarts() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
|
||||
}
|
||||
|
||||
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
|
||||
// stack, so the next press is a fresh phrase, not a takeover.
|
||||
static void testMonoLegatoAfterReleaseReattacks() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 150); // through the attack: at full level
|
||||
eng.noteOff(60); // release begins (stack now empty)
|
||||
out.clear();
|
||||
eng.render(out, 10);
|
||||
eng.noteOn(62, 127); // a NEW phrase: re-attacks even in Legato
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // fresh attack origin, not the ringing level
|
||||
}
|
||||
|
||||
// MONO does not apply the S16 Preserve cap: a single voice runs at most one shifter — a
|
||||
// Preserve->Preserve takeover must never be dropped by the cap.
|
||||
static void testMonoIgnoresPreserveCap() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
|
||||
VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
|
||||
CHECK(eng.noteOn(64, 127) == 0); // takeover NOT dropped (poly cap would drop it)
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
}
|
||||
|
||||
// The user-parameterized polyphony bound: an N-voice engine holds exactly N simultaneous
|
||||
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
||||
static void testVoiceCountBoundsPolyphony() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine e3(3, km);
|
||||
CHECK(e3.maxVoices() == 3);
|
||||
e3.noteOn(60, 127);
|
||||
e3.noteOn(62, 127);
|
||||
e3.noteOn(64, 127);
|
||||
CHECK(e3.activeVoiceCount() == 3);
|
||||
e3.noteOn(65, 127); // 4th: steals within the pool
|
||||
CHECK(e3.activeVoiceCount() == 3);
|
||||
|
||||
VoiceEngine e1(1, km);
|
||||
e1.noteOn(60, 127);
|
||||
e1.noteOn(62, 127);
|
||||
CHECK(e1.activeVoiceCount() == 1); // 1-voice pool: every note steals the one voice
|
||||
|
||||
VoiceEngine e0(0, km);
|
||||
CHECK(e0.maxVoices() == 1); // documented degenerate: clamped to 1
|
||||
}
|
||||
|
||||
// PREVIEW-CARD ISOLATION: the card never consumes a pool voice, a FULL pool never drops a
|
||||
// preview, and pool stealing never touches the ringing preview. The two sum independently.
|
||||
static void testPreviewCardIsolatedFromPool() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km);
|
||||
PreviewCard card(km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127); // the pool is now FULL
|
||||
card.noteOn(64, 127); // preview fires anyway — its own voice
|
||||
CHECK(eng.activeVoiceCount() == 2); // no pool voice consumed
|
||||
CHECK(card.active());
|
||||
std::vector<AudioSample> buf(4, 0.0f);
|
||||
eng.render(buf.data(), buf.size()); // engine sums 2 voices...
|
||||
card.render(buf.data(), buf.size()); // ...card ADDS its own on top
|
||||
CHECK(approx(buf[0], 3.0, 1e-6));
|
||||
eng.noteOn(64, 127); // pool steals INTERNALLY...
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
CHECK(card.active()); // ...the preview is untouched
|
||||
card.noteOff(64); // flat release: card gates off instantly
|
||||
std::vector<AudioSample> buf2(1, 0.0f);
|
||||
card.render(buf2.data(), buf2.size());
|
||||
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 2); // and the pool never noticed
|
||||
}
|
||||
|
||||
// The card is ONE voice: a new preview replaces the ringing one, a STALE note-off (for the
|
||||
// replaced note) is a no-op, and an out-of-zone preview is a defined no-play.
|
||||
static void testPreviewCardReplaceStaleOffAndOutOfZone() {
|
||||
Keymap km = twoLevelKeymap(); // zones [40,59] + [60,80]
|
||||
PreviewCard card(km);
|
||||
card.noteOn(50, 127);
|
||||
card.noteOn(70, 127); // replaces the first preview
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 0.75, 1e-6)); // zone B is what rings
|
||||
card.noteOff(50); // STALE off for the replaced note: no-op
|
||||
CHECK(card.active());
|
||||
card.noteOff(70); // the sounding note's off gates it (release 0)
|
||||
std::vector<AudioSample> buf2(1, 0.0f);
|
||||
card.render(buf2.data(), buf2.size());
|
||||
CHECK(approx(buf2[0], 0.0, 1e-9));
|
||||
card.noteOn(20, 127); // out of every zone: defined no-play
|
||||
CHECK(!card.active());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testChromaticSingleRoot();
|
||||
testZonedRangesBoundaries();
|
||||
@@ -1505,17 +1824,36 @@ int main() {
|
||||
testPreserveGateStereoLoopComposes();
|
||||
testPreserveVoiceCap();
|
||||
|
||||
// FA1 — Preserve unity bypass (preview latency) + velocity under Preserve.
|
||||
testPreserveUnityVoiceSpeaksImmediately();
|
||||
testPreserveKeyTrackZeroAlsoSpeaksImmediately();
|
||||
// FA1 (re-scoped by Phase S) — the unity bypass is preview-card-only; the engine keeps a
|
||||
// uniform Preserve onset. Velocity under Preserve unchanged.
|
||||
testPreserveUnityEngineVoiceKeepsUniformOnset();
|
||||
testPreviewCardUnitySpeaksImmediately();
|
||||
testPreviewCardKeyTrackZeroAlsoSpeaksImmediately();
|
||||
testPreviewCardTransposedKeepsShifter();
|
||||
testPreserveTransposedVoiceKeepsOlaPath();
|
||||
testPreserveUnityVoiceDoesNotConsumeCap();
|
||||
testPreserveUnityVoiceCountsTowardCap();
|
||||
testVelocityCurveAppliesUnderPreserve();
|
||||
|
||||
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
|
||||
testPerZoneAdsrReachesVoiceEnvelope();
|
||||
testZeroAdsrIsInstantSustain();
|
||||
|
||||
// Phase S — voice count, MONO mode (held stack + Retrigger/Legato), preview card.
|
||||
testMonoLastNotePriorityAndFallback();
|
||||
testMonoReleaseOfLowerHeldNoteIsInaudible();
|
||||
testMonoRepressHeldNoteMovesToTop();
|
||||
testMonoRetriggerFallbackUsesOriginalVelocity();
|
||||
testMonoOutOfZoneNeverJoinsStack();
|
||||
testMonoRetriggerRestartsEnvelope();
|
||||
testMonoLegatoContinuesEnvelope();
|
||||
testMonoLegatoRetunesWithoutReadRestart();
|
||||
testMonoLegatoCrossSampleRestarts();
|
||||
testMonoLegatoAfterReleaseReattacks();
|
||||
testMonoIgnoresPreserveCap();
|
||||
testVoiceCountBoundsPolyphony();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all sampler_core tests passed\n");
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user