s12: thread project rate through v3 legacy lift; remove 44100 literals

kLegacyV3NominalRate removed. readZonesPayload, deserializePerformance, deserializeComponentState now take a projectRate for v3 frames→seconds. Rate fields default 0 (invalid). Four 44100 fallbacks replaced with assert+safe-return. 96k v3-lift test added.
This commit is contained in:
2026-07-27 03:07:52 -04:00
parent a54af277e4
commit 082c9b82c2
6 changed files with 144 additions and 92 deletions
+40 -19
View File
@@ -4,6 +4,7 @@
#include "sample_map.h"
#include <algorithm> // std::min
#include <cassert> // assert
#include <cstring> // std::memcpy
#include <utility> // std::move
@@ -118,8 +119,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
out.sampleRate = sampleRate > 0 ? sampleRate : 44100;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
@@ -137,7 +140,8 @@ 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;
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
@@ -162,6 +166,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
@@ -169,7 +174,8 @@ Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
if (!framesR.empty() && framesR.size() == data.frames.size()) {
data.framesR = std::move(framesR);
}
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
data.sampleRate = sampleRate;
data.rootNote = rootNote;
data.loop = loop;
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
@@ -238,7 +244,10 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
decoded[i].framesR.size() == data.frames.size()) {
data.framesR = decoded[i].framesR;
}
data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100;
assert(decoded[i].sampleRate > 0 &&
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
data.sampleRate = decoded[i].sampleRate;
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
@@ -400,7 +409,10 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
// 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) {
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
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) {
@@ -435,18 +447,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
}
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).
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
// 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.
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
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 = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / kLegacyV3NominalRate;
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) {
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
@@ -482,7 +496,11 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
return out;
}
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
PerformanceMap map;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
@@ -503,7 +521,7 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes) {
}
if (version != kPerformanceStateVersion) return map; // unknown -> empty
readZonesPayload(r, map);
readZonesPayload(r, map, projectRate);
return map;
}
@@ -526,7 +544,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
return out;
}
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes) {
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
ComponentState out;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
@@ -549,8 +570,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
return out;
}
if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map); // v2 body starts right after the version tag
return out; // channelMode stays Mono (pre-S7)
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
return out; // channelMode stays Mono (pre-S7)
}
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
// the id length + id + zones body starts right after the version tag (no mode byte).
@@ -558,7 +579,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map);
readZonesPayload(r, out.map, projectRate);
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
}
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
@@ -571,7 +592,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map);
readZonesPayload(r, out.map, projectRate);
return out; // marker stays 0 (pre-S8/S9 reader)
}
if (version != kComponentStateVersion) return out; // unknown -> empty
@@ -587,7 +608,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes)
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map);
readZonesPayload(r, out.map, projectRate);
return out;
}