fix(S12 review): per-zone A/D/S/R reaches voice; zones payload v4 + v3 back-compat

Voice::start reads full zone ADSR instead of folding only holdFrames into instrument-wide gateAdsr. Wire format bumped to v4 (four new fields); v3 blobs lift A/D/S/R to kTier0Nominal* constants. Single-capture editor controls now reachable. Minor comment and geometry fixes.
This commit is contained in:
2026-07-27 01:38:48 -04:00
parent 4f30124ef7
commit 7fb778206c
6 changed files with 266 additions and 42 deletions
+69 -8
View File
@@ -383,10 +383,14 @@ static void testOutOfZoneNoteConsumesNoVoice() {
// ---------------------------------------------------------------------------
static void testStealsReleasingVoiceFirst() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
AdsrParams a = flatAdsr();
a.releaseFrames = 100000; // long release so a released voice stays "active".
VoiceEngine eng(2, km, a);
// Long per-zone release so the voice stays active through the release tail.
// Per the S12 fix, Voice::start uses sample.play.adsr — not the engine's gateAdsr —
// so the long release must live on the SampleData, not on the VoiceEngine constructor arg.
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr());
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -406,10 +410,12 @@ static void testStealsReleasingVoiceFirst() {
}
static void testStealsOldestWhenNoneReleasing() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100000, 60));
AdsrParams a = flatAdsr();
a.releaseFrames = 100000;
VoiceEngine eng(2, km, a);
// Long per-zone release — placed on SampleData.play.adsr per the S12 fix.
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(2, km, flatAdsr());
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
std::size_t vB = eng.noteOn(62, 100); // startOrder 2
@@ -1177,6 +1183,57 @@ static void testPreserveVoiceCap() {
CHECK(eng.activeVoiceCount() == 2);
}
// --- S12 review fix: per-zone A/D/S/R actually reaches the voice envelope. ---
//
// Before the fix, Voice::start used the instrument-wide gateAdsr for A/D/S/R and only
// folded the per-zone holdFrames. These two tests assert the corrected path.
// The zone's attackFrames drives the envelope ramp — NOT the VoiceEngine's gateAdsr.
// Strategy: give the VoiceEngine a FLAT gateAdsr (instant attack) but put an explicit
// 10-frame attack on the SampleData.play.adsr. If Voice::start reads the zone ADSR, the
// DC-1 output will be 0 at frame 0 and 1.0 after the 10-frame ramp. If it instead used
// gateAdsr (flat = instant), frame 0 would already be 1.0. This is the load-bearing proof.
static void testPerZoneAdsrReachesVoiceEnvelope() {
SampleData s = dcSample(500, 60);
// Per-zone attack = 10 frames, zero decay, sustain 1.0, zero release.
s.play.adsr.attackFrames = 10;
s.play.adsr.holdFrames = 0;
s.play.adsr.decayFrames = 0;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr()); // instrument-wide gateAdsr = flat (instant attack)
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out;
eng.render(out, 20);
// Frame 0: attack start, envelope near 0. If gateAdsr (flat) were used, this would be 1.0.
CHECK(approx(out[0], 0.0, 1e-9)); // env still at bottom of ramp
// Frame 9: still ramping (last attack frame, linear ramp reaches 0.9).
CHECK(out[9] < 1.0 - 1e-9);
// Frame 10+: attack complete, sustain at 1.0.
CHECK(approx(out[10], 1.0, 1e-9));
CHECK(approx(out[19], 1.0, 1e-9));
}
// Default-valued zone (AdsrParams all zeros) is behavior-identical to the pre-fix flat path.
// A zero-init AdsrParams (attackFrames=0, decayFrames=0, sustainLevel=1.0, releaseFrames=0) must
// yield an instant-attack/instant-sustain voice — frame 0 immediately at 1.0. This preserves the
// back-compat invariant: an old zone with no A/D/S/R storage sounds the same as before.
static void testZeroAdsrIsInstantSustain() {
SampleData s = dcSample(20, 60);
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
// All frames must be 1.0: zero attack + sustain 1.0 = instantly at full level.
for (std::size_t i = 0; i < out.size(); ++i) CHECK(approx(out[i], 1.0, 1e-9));
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
@@ -1228,6 +1285,10 @@ int main() {
testPreserveGateStereoLoopComposes();
testPreserveVoiceCap();
// S12 review fix — per-zone A/D/S/R reaches the voice envelope.
testPerZoneAdsrReachesVoiceEnvelope();
testZeroAdsrIsInstantSustain();
if (g_fail == 0) {
std::printf("all sampler_core tests passed\n");
return 0;