Merge pS-voice: voice-system redesign — user-set polyphony, mono (retrig|legato), isolated preview card, two-tier panic (v7); incorporates FA1 realtime fixes
This commit is contained in:
@@ -1105,6 +1105,121 @@ static void testComponentStateV6TruncatedVelocity() {
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- v7 component state: the Phase S voice-system fields (count / mode / trigger) -------------
|
||||
|
||||
static void testComponentStateVoiceSystemRoundTrip() {
|
||||
// Non-default values on all three fields prove the bytes are read back, not defaulted; the
|
||||
// envelope neighbours (mode, marker, velocity, selection, zones) ride alongside intact.
|
||||
ComponentState s;
|
||||
s.selectionId = "pick";
|
||||
s.channelMode = ChannelMode::Stereo;
|
||||
s.lastConsumedAssignGeneration = 42;
|
||||
s.previewVelocity = 99;
|
||||
s.voiceCount = 5;
|
||||
s.voiceMode = VoiceMode::Mono;
|
||||
s.monoTrigger = MonoTrigger::Legato;
|
||||
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.voiceCount == 5);
|
||||
CHECK(back.voiceMode == VoiceMode::Mono);
|
||||
CHECK(back.monoTrigger == MonoTrigger::Legato);
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
CHECK(back.lastConsumedAssignGeneration == 42);
|
||||
CHECK(back.previewVelocity == 99);
|
||||
CHECK(back.selectionId == "pick");
|
||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
||||
}
|
||||
|
||||
static void testComponentStateVoiceDefaultsRoundTrip() {
|
||||
// A default-constructed state carries {16, Poly, Retrigger} — the pre-Phase-S behavior —
|
||||
// and round-trips it. Locks the constants the engine + editor share.
|
||||
const ComponentState back =
|
||||
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||
CHECK(kDefaultVoiceCount == 16 && kMinVoiceCount == 1 && kMaxVoiceCount == 32);
|
||||
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||
}
|
||||
|
||||
static void testComponentStateVoiceCountExtremesRoundTrip() {
|
||||
// Both range edges survive the single-byte field exactly.
|
||||
for (int vc : {kMinVoiceCount, kMaxVoiceCount}) {
|
||||
ComponentState s;
|
||||
s.voiceCount = vc;
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.voiceCount == vc);
|
||||
}
|
||||
}
|
||||
|
||||
static void testComponentStateVoiceCountWriterClamps() {
|
||||
// The WRITER never emits an out-of-range byte: above-max clamps to max; a nonsensical
|
||||
// below-min value (a programming error upstream) falls back to the default.
|
||||
ComponentState hi;
|
||||
hi.voiceCount = 99;
|
||||
CHECK(deserializeComponentState(serializeComponentState(hi), 44100.0).voiceCount ==
|
||||
kMaxVoiceCount);
|
||||
ComponentState lo;
|
||||
lo.voiceCount = 0;
|
||||
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).voiceCount ==
|
||||
kDefaultVoiceCount);
|
||||
}
|
||||
|
||||
static void testComponentStateV6LiftsVoiceDefaults() {
|
||||
// A GENUINE v6 blob (version tag 6: mode, marker, velocity, id, zones — NO voice bytes)
|
||||
// lifts to the Phase S voice defaults {16, Poly, Retrigger}, its other fields intact.
|
||||
// Hand-built (serializeComponentState now emits v7, so it cannot make a v6 blob). This
|
||||
// proves an already-saved pre-Phase-S instance restores playing exactly as it did.
|
||||
std::vector<std::uint8_t> v6;
|
||||
v6.push_back(6); v6.push_back(0); v6.push_back(0); v6.push_back(0); // version 6
|
||||
v6.push_back(1); // channel mode = stereo
|
||||
for (int i = 0; i < 8; ++i) v6.push_back(0); // marker = 0
|
||||
v6.push_back(111); // preview velocity
|
||||
const std::string id = "saved";
|
||||
v6.push_back(static_cast<std::uint8_t>(id.size())); v6.push_back(0); v6.push_back(0); v6.push_back(0);
|
||||
v6.insert(v6.end(), id.begin(), id.end());
|
||||
v6.push_back(0); v6.push_back(0); v6.push_back(0); v6.push_back(0); // zone count 0
|
||||
const ComponentState back = deserializeComponentState(v6, 44100.0);
|
||||
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||
CHECK(back.previewVelocity == 111); // the v6 byte still honored
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
CHECK(back.selectionId == "saved");
|
||||
CHECK(back.map.zones.empty());
|
||||
}
|
||||
|
||||
static void testComponentStateV7CorruptVoiceBytesFallBack() {
|
||||
// Out-of-range voice bytes in a v7 blob fall back to each field's DEFAULT (the
|
||||
// previewVelocity corrupt-byte precedent) — a corrupt blob never silences or distorts the
|
||||
// instance to an edge the user never chose. Build v7 by serializing, then vandalize the
|
||||
// three voice bytes in place (offsets: 4 version + 1 mode + 8 marker + 1 velocity = 14).
|
||||
ComponentState s;
|
||||
s.voiceCount = 7;
|
||||
s.voiceMode = VoiceMode::Mono;
|
||||
s.monoTrigger = MonoTrigger::Legato;
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||
bytes[14] = 0; // voice count 0: below kMinVoiceCount
|
||||
bytes[15] = 7; // voice mode: not a legal {0,1} value
|
||||
bytes[16] = 9; // mono trigger: not a legal {0,1} value
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||
CHECK(back.voiceMode == VoiceMode::Poly); // non-1 mode byte -> Poly default
|
||||
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||
}
|
||||
|
||||
static void testComponentStateV7TruncatedVoiceBytes() {
|
||||
// A v7 blob cut INSIDE the three voice bytes -> empty, defaults holding (bounded read).
|
||||
std::vector<std::uint8_t> t{7, 0, 0, 0, 1}; // version 7, mode byte
|
||||
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
|
||||
t.push_back(64); // velocity byte
|
||||
t.push_back(16); // voice count only —
|
||||
const ComponentState back = deserializeComponentState(t, 44100.0); // mode/trigger cut
|
||||
CHECK(back.voiceCount == kDefaultVoiceCount);
|
||||
CHECK(back.voiceMode == VoiceMode::Poly);
|
||||
CHECK(back.monoTrigger == MonoTrigger::Retrigger);
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||
//
|
||||
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||
@@ -1419,6 +1534,50 @@ static void testVelocityCurveResolvesToZone() {
|
||||
CHECK(r.zones[0].velocityCurve.equals(vst::VelocityCurve::linear()));
|
||||
}
|
||||
|
||||
// FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition
|
||||
// exactly: an authored curve survives the component-state round-trip (the save/load seam), then
|
||||
// resolvePerformance -> buildZonedKeymap -> VoiceEngine (constructed with a Preserve window, the
|
||||
// DAW configuration) -> render, and the rendered level tracks velocity through the curve. This
|
||||
// is the full pure slice of the click-to-sound path; only the bridge read + WAV decode (shell
|
||||
// I/O) are outside it. A y=x curve at velocity 1 must be near-silent — NOT max volume.
|
||||
static void testVelocityCurveEndToEndThroughReloadComposition() {
|
||||
// 1. The instrument's own state: one full-keyboard zone with a LINEAR curve (the exact edit
|
||||
// Daniel made), round-tripped through the v7 component-state wire (save -> load).
|
||||
ComponentState s;
|
||||
s.selectionId = "a";
|
||||
PerformanceZone z = zone("a", 0, 127);
|
||||
z.velocityCurve = vst::VelocityCurve::linear();
|
||||
s.map.zones.push_back(z);
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0);
|
||||
CHECK(back.map.zones.size() == 1);
|
||||
if (back.map.zones.size() != 1) return;
|
||||
|
||||
// 2. Resolve against a live bank blob (the shared bank_book parse, root 60 intrinsic).
|
||||
const std::string json = bookJson({makeSample("a", "Kick", "reasampler_bank/a.wav", 60)}, {});
|
||||
const ResolvedPerformance rp = resolvePerformance(json, back.map);
|
||||
CHECK(rp.zones.size() == 1);
|
||||
if (rp.zones.size() != 1) return;
|
||||
// The round-tripped zone still runs the PRESERVE product default (the DAW engine config).
|
||||
CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve);
|
||||
|
||||
// 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed
|
||||
// the way reloadFromBank constructs it (Preserve voices pre-sized to a real window).
|
||||
auto steadyLevelAt = [&](int vel) -> double {
|
||||
DecodedZonePcm pcm;
|
||||
pcm.monoFrames.assign(4000, 1.0f);
|
||||
pcm.sampleRate = 48000;
|
||||
const Keymap km = buildZonedKeymap(rp.zones, {pcm});
|
||||
VoiceEngine eng(16, km, /*preserveCap=*/8, /*window=*/256);
|
||||
eng.noteOn(62, vel); // transposed: the genuine OLA shifter path
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(out[900]); // steady state (ring fully DC past the window)
|
||||
};
|
||||
CHECK(approx(steadyLevelAt(127), 1.0));
|
||||
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0));
|
||||
CHECK(steadyLevelAt(1) < 0.02); // velocity 1 through y=x: near-silent, never max volume
|
||||
}
|
||||
|
||||
static void testVelocityCurveV6BackCompatLiftsToFlat() {
|
||||
// A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field)
|
||||
// lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE
|
||||
@@ -1822,6 +1981,7 @@ int main() {
|
||||
testVelocityCurveRoundTrip();
|
||||
testVelocityCurveThroughComponentEnvelope();
|
||||
testVelocityCurveResolvesToZone();
|
||||
testVelocityCurveEndToEndThroughReloadComposition();
|
||||
testVelocityCurveV6BackCompatLiftsToFlat();
|
||||
testPlayParamsV2BackCompatLiftsToDefaults();
|
||||
testPlayParamsThroughComponentEnvelope();
|
||||
@@ -1863,6 +2023,13 @@ int main() {
|
||||
testComponentStateV5LiftsVelocityToMid();
|
||||
testComponentStateV4LiftsVelocityToMid();
|
||||
testComponentStateV6TruncatedVelocity();
|
||||
testComponentStateVoiceSystemRoundTrip();
|
||||
testComponentStateVoiceDefaultsRoundTrip();
|
||||
testComponentStateVoiceCountExtremesRoundTrip();
|
||||
testComponentStateVoiceCountWriterClamps();
|
||||
testComponentStateV6LiftsVoiceDefaults();
|
||||
testComponentStateV7CorruptVoiceBytesFallBack();
|
||||
testComponentStateV7TruncatedVoiceBytes();
|
||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||
testReconcileKeepsOnlySelectedFullRangeZone();
|
||||
|
||||
+626
-2
@@ -19,6 +19,7 @@
|
||||
|
||||
#include "../src/vst/sampler_core.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
@@ -1291,18 +1292,155 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
}
|
||||
|
||||
// --- Preserve voice cap: a Preserve note-on past the cap is dropped; Varispeed unaffected. ---
|
||||
// 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)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
// 8 voices total, Preserve cap of 2.
|
||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
CHECK(eng.noteOn(64, 127) != VoiceEngine::kNoVoice); // 2nd Preserve voice (at the cap)
|
||||
CHECK(eng.noteOn(65, 127) == VoiceEngine::kNoVoice); // 3rd DROPPED by the Preserve cap
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FA1 (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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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 — NO demotion in the MIDI engine
|
||||
std::vector<AudioSample> out;
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
// half-window cost of preserving duration) and the voice reaches full level once the ring fills.
|
||||
// Also proves the demotion is unity-ONLY — the shifter still transposes off-root notes.
|
||||
static void testPreserveTransposedVoiceKeepsOlaPath() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1500);
|
||||
// Early frames are the shifter's fill (near-silent) — the structural OLA onset.
|
||||
double early = 0.0;
|
||||
for (std::size_t i = 0; i < 8; ++i) {
|
||||
early = (std::max)(early, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(early < 0.1);
|
||||
// Once the ring is full of the DC source (>= window frames in), output reaches the sample
|
||||
// level (Hann taps partition unity, so DC passes at gain 1).
|
||||
double late = 0.0;
|
||||
for (std::size_t i = 600; i < 1500; ++i) {
|
||||
late = (std::max)(late, static_cast<double>(std::fabs(out[i])));
|
||||
}
|
||||
CHECK(late > 0.9);
|
||||
}
|
||||
|
||||
// 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); // 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
|
||||
// gain under the PRESERVE product-default engine with a CONFIGURED shifter window (every prior
|
||||
// velocity test ran the bare Varispeed core). A linear y=x curve at velocity 1 must be
|
||||
// near-silent — NOT max volume.
|
||||
static void testVelocityCurveAppliesUnderPreserve() {
|
||||
auto steadyLevelAt = [&](int vel) -> double {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = vst::VelocityCurve::linear();
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
|
||||
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1000);
|
||||
return static_cast<double>(out[900]); // steady state: ring is fully DC by frame 256
|
||||
};
|
||||
CHECK(approx(steadyLevelAt(127), 1.0, 0.02));
|
||||
CHECK(approx(steadyLevelAt(64), 64.0 / 127.0, 0.02));
|
||||
CHECK(steadyLevelAt(1) < 0.02); // y=x at velocity 1: near-silent, the Daniel repro case
|
||||
}
|
||||
|
||||
// --- Per-zone A/D/S/R actually reaches the voice envelope (S12). ---
|
||||
//
|
||||
// Every AHDSR field rides on SampleData.play.adsr (frames, resolved from the stored seconds at
|
||||
@@ -1353,6 +1491,457 @@ 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);
|
||||
}
|
||||
|
||||
// A ramp sample (output value == read position) so a re-attack (read restarts at 0) is
|
||||
// directly distinguishable from a legato retune (read continues) on the rendered value.
|
||||
static SampleData rampSample(std::size_t frames, int rootNote) {
|
||||
SampleData s;
|
||||
s.frames.resize(frames);
|
||||
for (std::size_t i = 0; i < frames; ++i) s.frames[i] = static_cast<float>(i);
|
||||
s.rootNote = rootNote;
|
||||
s.play.adsr = flatAdsr();
|
||||
return s;
|
||||
}
|
||||
|
||||
// MAJOR-1 regression: MONO+LEGATO with a TRIGGER zone RE-ATTACKS after the last key is up.
|
||||
// Trigger ignores note-off (Voice::release() is a no-op, so releasing_ never latches), so a
|
||||
// legato guard keyed on `active && !releasing` saw a ringing one-shot as "still held" and
|
||||
// silently RETUNED it in place. The correct predicate is the HELD-STACK depth: with no other
|
||||
// key down, the next note is a fresh phrase and must restart the read head.
|
||||
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // unity: read advances 1/frame
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10);
|
||||
eng.noteOff(60); // Trigger ignores the gate: keeps ringing...
|
||||
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // ...read head still advancing past 10
|
||||
eng.noteOn(62, 127); // NO key held -> fresh phrase: RE-ATTACK
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (a retune would read ~11)
|
||||
// And it is genuinely playing from the top at the new pitch (ratio 2^(2/12) ~ 1.1225),
|
||||
// not merely silent: the next frame reads at the advanced position.
|
||||
CHECK(approx(probeFrame(eng), std::pow(2.0, 2.0 / 12.0), 1e-3));
|
||||
}
|
||||
|
||||
// Companion boundary: with another key STILL physically held, a same-sample Trigger takeover
|
||||
// under Legato still RETUNES (read continues) — the held-stack predicate matches the old
|
||||
// behavior everywhere except the ringing-but-unheld case above.
|
||||
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
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, 10);
|
||||
eng.noteOn(62, 127); // 60 still held -> legato takeover
|
||||
CHECK(approx(probeFrame(eng), 10.0, 1e-4)); // read CONTINUES at 10 — no re-attack
|
||||
}
|
||||
|
||||
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
|
||||
static void testAllNotesOffReleasesPolyVoices() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127);
|
||||
eng.noteOn(64, 127);
|
||||
CHECK(eng.activeVoiceCount() == 3);
|
||||
eng.allNotesOff();
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); // all gated off (release 0 = instant)
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
|
||||
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
|
||||
static void testAllNotesOffClearsMonoHeldStack() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
|
||||
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
|
||||
eng.allNotesOff(); // PANIC
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
|
||||
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MAJOR-2 companion: the preview card's unconditional releaseAll (the panic peer).
|
||||
static void testPreviewCardReleaseAll() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
PreviewCard card(km);
|
||||
card.noteOn(70, 127);
|
||||
CHECK(card.active());
|
||||
card.releaseAll(); // no note argument: quiets whatever rings
|
||||
std::vector<AudioSample> buf(1, 0.0f);
|
||||
card.render(buf.data(), buf.size());
|
||||
CHECK(approx(buf[0], 0.0, 1e-9));
|
||||
CHECK(!card.active());
|
||||
}
|
||||
|
||||
// CC 120 (allSoundsOff) hard-stops a ringing TRIGGER one-shot that would otherwise play to
|
||||
// its bounded playEnd (minutes on a full-length capture). This is the primary repro: allNotesOff
|
||||
// (CC 123) is a NO-OP on a Trigger voice — only allSoundsOff provides the actual hard stop.
|
||||
static void testAllSoundsOffStopsTriggerOneShot() {
|
||||
// A Trigger sample with a very long play length (all-1 DC, flat velocity). After noteOn the
|
||||
// voice is active and ringing; allSoundsOff must silence it immediately.
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
// CC 123 (release) must be a NO-OP on a Trigger voice — the one-shot plays through.
|
||||
eng.allNotesOff();
|
||||
CHECK(eng.activeVoiceCount() == 1); // still ringing (Trigger ignores release)
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
CHECK(out[0] > 0.5f); // still sounding
|
||||
// CC 120 (hard-stop) must silence it instantly.
|
||||
eng.allSoundsOff();
|
||||
CHECK(eng.activeVoiceCount() == 0); // immediately idle
|
||||
out.clear();
|
||||
eng.render(out, 1);
|
||||
CHECK(approx(out[0], 0.0, 1e-9)); // silent
|
||||
}
|
||||
|
||||
// CC 123 (allNotesOff) still releases Gate voices — the existing release behavior is unchanged.
|
||||
static void testAllNotesOffStillReleasesGateVoices() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
// Default Gate mode, instant release (releaseFrames 0).
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127);
|
||||
CHECK(eng.activeVoiceCount() == 2);
|
||||
eng.allNotesOff();
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 1);
|
||||
CHECK(approx(out[0], 0.0, 1e-9)); // Gate with 0-release: instant silence
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// MONO LEGATO same-note re-press (one-held-note edge case): with only that note on the
|
||||
// stack, heldCount_ after the re-push is 1 (not >= 2), so it falls through to re-attack
|
||||
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
|
||||
static void testMonoLegatoSameNoteRepressReattacks() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // first press; read starts at 0
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 10); // advance the read head to ~10
|
||||
// Re-press the SAME note while it is the only held note: heldCount_ after removeHeld+push = 1
|
||||
// -> does NOT satisfy heldCount_ >= 2 -> re-attack (not a legato retune).
|
||||
eng.noteOn(60, 127);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-4)); // read RESTARTED at 0 (re-attack, not retune)
|
||||
}
|
||||
|
||||
// GREEN: out-of-range notes are rejected at BOTH mono entry points. The held stack stores
|
||||
// uint8, so an unguarded off for note 256 (== 0 mod 256) would alias-evict held note 0 —
|
||||
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
|
||||
static void testMonoOutOfRangeNotesRejected() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
eng.noteOn(0, 127); // hold the aliasing target (note 0)
|
||||
eng.noteOn(62, 127); // 62 takes the voice; 0 held beneath
|
||||
eng.noteOff(256); // MUST NOT alias-evict held note 0
|
||||
eng.noteOff(-256); // likewise for the negative wrap
|
||||
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // 62 undisturbed
|
||||
eng.noteOff(62); // falls back to STILL-HELD note 0
|
||||
CHECK(approx(probeFrame(eng), 1.0, 1e-6)); // (alias-evicted pre-fix -> silence here)
|
||||
eng.noteOff(0);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -1409,10 +1998,45 @@ int main() {
|
||||
testPreserveGateStereoLoopComposes();
|
||||
testPreserveVoiceCap();
|
||||
|
||||
// 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();
|
||||
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();
|
||||
testMonoLegatoTriggerReattacksAfterKeyUp();
|
||||
testMonoLegatoTriggerHeldKeyStillRetunes();
|
||||
testAllNotesOffReleasesPolyVoices();
|
||||
testAllNotesOffClearsMonoHeldStack();
|
||||
testPreviewCardReleaseAll();
|
||||
testAllSoundsOffStopsTriggerOneShot();
|
||||
testAllNotesOffStillReleasesGateVoices();
|
||||
testMonoLegatoSameNoteRepressReattacks();
|
||||
testMonoOutOfRangeNotesRejected();
|
||||
testVoiceCountBoundsPolyphony();
|
||||
testPreviewCardIsolatedFromPool();
|
||||
testPreviewCardReplaceStaleOffAndOutOfZone();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("all sampler_core tests passed\n");
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user