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
+26 -1
View File
@@ -356,6 +356,13 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putU64le(out, asU64(pp.pitchEnv.attackFrames));
putU64le(out, asU64(pp.pitchEnv.decayFrames));
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones));
// S12 review fix (PAYLOAD v4): the full per-zone AHDSR A/D/S/R tail (always present in v4).
// Voice::start now uses the zone's full ADSR; old v3 blobs lift to tier-0 nominal defaults
// at read time (see readZonesPayload) so the voice sounds bit-identical to the pre-fix build.
putU64le(out, asU64(pp.adsr.attackFrames));
putU64le(out, asU64(pp.adsr.decayFrames));
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, asU64(pp.adsr.releaseFrames));
}
}
@@ -367,11 +374,13 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
bool extended = false; // v2+: the S11 loop/start tail is present
bool hasPlay = false; // v3+: the S15/S16 play-params tail is present
bool hasAdsr = false; // v4+: the full A/D/S/R per-zone tail is present
if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail
hasPlay = (pv >= 3); // v3+ carries the S15/S16 play-params tail
hasAdsr = (pv >= 4); // v4+ carries the full A/D/S/R tail
}
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
@@ -397,7 +406,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
if (hasStart) z.startPoint = r.i64();
}
if (hasPlay) {
// S15/S16 play params, always present in a v3 record (read in the emit order).
// S15/S16 play params, always present in a v3+ record (read in the emit order).
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdFrames = r.i64();
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
@@ -409,6 +418,22 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
z.play.pitchEnv.decayFrames = r.i64();
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
}
if (hasAdsr) {
// S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order.
z.play.adsr.attackFrames = r.i64();
z.play.adsr.decayFrames = r.i64();
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseFrames = r.i64();
} else if (hasPlay) {
// v3 blob: A/D/S/R fields are absent. Lift to the tier-0 nominal defaults (44100 Hz)
// so a voice playing this zone sounds bit-identical to the pre-v4 build (back-compat).
// Voice::start now uses the zone's full ADSR; a zone with these values reproduces
// the instrument-wide tier0Adsr behavior the pre-fix code applied unconditionally.
z.play.adsr.attackFrames = kTier0NominalAttackFrames;
z.play.adsr.decayFrames = kTier0NominalDecayFrames;
z.play.adsr.sustainLevel = kTier0NominalSustainLevel;
z.play.adsr.releaseFrames = kTier0NominalReleaseFrames;
}
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}