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
+60
View File
@@ -1138,6 +1138,64 @@ static void testPlayParamsThroughComponentEnvelope() {
CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed);
}
// --- S12 review fix (PAYLOAD v4): full A/D/S/R per-zone round-trip. ---------------------
//
// Before the fix, per-zone A/D/S/R (attack/decay/sustain/release) was not serialized;
// only holdFrames was written. These two tests assert the corrected v4 path.
// All five AHDSR fields (including the four new A/D/S/R) must round-trip through the v4 payload.
static void testFullAdsrV4RoundTrip() {
PerformanceMap m;
PerformanceZone z = zone("pad", 0, 127);
z.play.playMode = PlayMode::Gate;
z.play.adsr.attackFrames = 441; // 0.01 s at 44100 Hz (a non-default value)
z.play.adsr.holdFrames = 882;
z.play.adsr.decayFrames = 4410; // 0.1 s
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseFrames = 8820; // 0.2 s
z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrParams& a = back.zones[0].play.adsr;
CHECK(a.attackFrames == 441);
CHECK(a.holdFrames == 882);
CHECK(a.decayFrames == 4410);
CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast
CHECK(a.releaseFrames == 8820);
CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve);
}
// A genuine PAYLOAD v3 blob (S15/S16 build — has holdFrames but not A/D/S/R) must lift
// attackFrames / decayFrames / sustainLevel / releaseFrames to the tier-0 nominal defaults
// (kTier0Nominal* constants) so the voice sounds bit-identical to the pre-fix behavior.
// We reuse handBuildV3PayloadOneZone which emits a valid marker-versioned v3 payload.
static void testV3BlobLiftsAdsrToNominalDefaults() {
// Build a PERFORMANCE blob: 4-byte kPerformanceStateVersion header + v3 zones payload.
// deserializePerformance strips the 4-byte header and passes the rest to readZonesPayload,
// which self-selects the v3 record shape from the payload marker+version — exercising the
// real production lift path for a user who saved on the S15 build.
std::vector<std::uint8_t> blob;
auto u32 = [&](std::uint32_t v) {
blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF);
blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF);
};
u32(kPerformanceStateVersion); // envelope version 2 header
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("old");
blob.insert(blob.end(), payload.begin(), payload.end());
const PerformanceMap back = deserializePerformance(blob);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrParams& a = back.zones[0].play.adsr;
// holdFrames comes from the v3 record itself; A/D/S/R must lift to the nominal tier-0 values.
CHECK(a.holdFrames == 2048); // from the hand-built v3 record
CHECK(a.attackFrames == kTier0NominalAttackFrames); // 132 (0.003 s at 44100)
CHECK(a.decayFrames == kTier0NominalDecayFrames); // 0
CHECK(a.sustainLevel == kTier0NominalSustainLevel); // 1.0
CHECK(a.releaseFrames == kTier0NominalReleaseFrames); // 2646 (0.060 s at 44100)
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -1189,6 +1247,8 @@ int main() {
testPlayParamsComposeWithLoopStart();
testPlayParamsV2BackCompatLiftsToDefaults();
testPlayParamsThroughComponentEnvelope();
testFullAdsrV4RoundTrip();
testV3BlobLiftsAdsrToNominalDefaults();
testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones();