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:
+83
-84
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user