S12: store wall-clock ADSR/pitch-env as seconds, resolve to frames at live rate

Kill kTier0Nominal*, adsrNeedsRateResolve, tier0Adsr, gateAdsr. Zones payload v5 carries
seconds; v3 legacy reads convert at the frozen authoring rate; v4 (branch-only) dropped.
Editor sliders now seconds. Engine takes frames resolved at keymap build.
This commit is contained in:
2026-07-27 02:51:46 -04:00
parent 1d338318e7
commit a54af277e4
10 changed files with 539 additions and 433 deletions
+83 -84
View File
@@ -133,9 +133,35 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 44100.0;
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
ZonePlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.trigger = stored.trigger; // source-frame / fraction, unchanged
out.pitchEngine = stored.pitchEngine;
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlayParams& play) {
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -146,23 +172,8 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
data.rootNote = rootNote;
data.loop = loop;
data.play = play; // S15/S16 single-capture play params (product defaults unless overridden)
// The default `play` arg carries 44100-Hz nominal ADSR frame counts (kTier0Nominal*).
// Rescale A/D/R by (sampleRate / 44100) so the wall-clock ADSR matches tier0Adsr(sampleRate)
// exactly. Sustain (a level, not a frame count) is unchanged. At 44100 the factor is 1.0 —
// bit-identical to the pre-fix build. The tier-0 single-capture path never carries user-edited
// ADSR (users edit ADSR through zones, which go through buildZonedKeymap), so always rescaling
// here is correct and safe.
if (data.sampleRate != 44100) {
const double factor = static_cast<double>(data.sampleRate) / 44100.0;
data.play.adsr.attackFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.attackFrames) * factor + 0.5);
data.play.adsr.decayFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.decayFrames) * factor + 0.5);
data.play.adsr.releaseFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.releaseFrames) * factor + 0.5);
}
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
data.play = resolvePlay(play, data.sampleRate);
return Keymap::singleSampleChromatic(std::move(data));
}
@@ -204,11 +215,9 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
// never mutated — this only shapes what the core plays for THIS instance (D-B).
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params carry through unchanged (they are instrument state, not
// resolved against the bank) so the keymap build can stamp them onto the SampleData.
// S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument
// state, not resolved against the bank); buildZonedKeymap resolves them to frames.
rz.play = z.play;
// Carry the rate-resolve flag so buildZonedKeymap can rescale 44100-nominal ADSR counts.
rz.adsrNeedsRateResolve = z.adsrNeedsRateResolve;
out.zones.push_back(std::move(rz));
}
return out;
@@ -233,21 +242,9 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
data.play = zones[i].play; // S15/S16 per-zone play mode + engine + envelopes
// If the zone's ADSR came from a v3 lift or a new-zone default (adsrNeedsRateResolve),
// its A/D/R frame counts are 44100-Hz nominals. Rescale to the WAV's actual rate so
// wall-clock ADSR durations match tier0Adsr(sampleRate) exactly. v4 zones (user-edited
// frame counts) carry adsrNeedsRateResolve=false and are left unchanged.
if (zones[i].adsrNeedsRateResolve && data.sampleRate != 44100) {
const double factor = static_cast<double>(data.sampleRate) / 44100.0;
data.play.adsr.attackFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.attackFrames) * factor + 0.5);
data.play.adsr.decayFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.decayFrames) * factor + 0.5);
data.play.adsr.releaseFrames = static_cast<std::int64_t>(
static_cast<double>(data.play.adsr.releaseFrames) * factor + 0.5);
}
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data));
KeyZone zone;
@@ -376,26 +373,25 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 extension (PAYLOAD v3): the per-zone play params, always present (every zone
// has a play mode + engine — no flag gate). Order matches the header's v3 record spec.
const ZonePlayParams& pp = z.play;
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
// fraction. Order matches the header's v5 record spec.
const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, asU64(pp.adsr.holdFrames));
putU64le(out, doubleToBits(pp.trigger.lengthFraction));
putU64le(out, asU64(pp.trigger.fadeInFrames));
putU64le(out, asU64(pp.trigger.fadeOutFrames));
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
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.pitchEnv.attackSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, asU64(pp.adsr.releaseFrames));
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
}
}
@@ -405,20 +401,19 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
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
bool extended = false; // v2+: the S11 loop/start tail is present
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker
const std::uint32_t pv = r.u32(); // payload version
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 bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
const bool secondsPlay = (pv >= 5); // current: full play params, wall-clock in seconds
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve). A v1/v2 payload (no play
// tail) therefore lifts every zone to those defaults — the deliberate S16-F1 change.
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
@@ -438,37 +433,41 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
}
if (hasPlay) {
// S15/S16 play params, always present in a v3+ record (read in the emit order).
if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
// were written as 44.1k-nominal frames -> divide by kLegacyV3NominalRate to reach the
// seconds domain. Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are
// ABSENT in v3 -> leave the seconds defaults on z.play.adsr (0.003 / 0 / 1.0 / 0.060).
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdFrames = r.i64();
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackFrames = r.i64();
z.play.pitchEnv.decayFrames = r.i64();
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
// frames; read in the emit order.
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
if (hasAdsr) {
// S12 review fix (v4): the full per-zone A/D/S/R tail — read in the emit order.
// These values were authored at the DAW's rate; mark them resolved (no rescaling).
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();
z.adsrNeedsRateResolve = false; // already rate-resolved; do NOT rescale at keymap build
} 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;
}
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}