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
+5 -1
View File
@@ -176,7 +176,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
// blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so
// the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10
// silent empty state (no first-sample fallback in reloadFromBank).
const ComponentState cs = deserializeComponentState(bytes);
// Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at
// the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing
// before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a
// pre-setup call would assert inside readZonesPayload (a programming error, not a field case).
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
setSelectedSampleId(cs.selectionId);
setPerformanceMap(cs.map);
// S8: restore the last-consumed assignment generation so a re-open does not re-apply a
+3 -2
View File
@@ -251,8 +251,9 @@ private:
std::int64_t lastSeenBankGeneration_ = -1;
// Latched from setupProcessing so setActive/reload can size against it. Read
// off-thread only.
double sampleRate_ = 44100.0;
// off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate
// before any audio, and reloadFromBank guards on it before use.
double sampleRate_ = 0.0;
Steinberg::int32 maxBlockSize_ = 4096;
// --- S6 embedded TCP/MCP UI ---------------------------------------------
+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;
}
+18 -12
View File
@@ -260,7 +260,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
// map). Empty zones in -> empty Keymap (silence).
struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 44100;
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
@@ -323,10 +324,10 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as 44.1k-nominal frames (that build's slider domain was fixed at
// 44100), so they convert to the seconds domain by dividing by that authoring-time nominal rate
// (kLegacyV3NominalRate). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060), no rate.
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
@@ -364,18 +365,20 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
inline constexpr std::uint32_t kZonesPayloadVersion = 5; // S12: full per-zone play params, SECONDS
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// The authoring-time nominal rate the LEGACY v3 zone payload's wall-clock frame counts (hold,
// pitchEnv A/D) were always written at (the S15/S16 editor's slider domain was fixed at 44100 Hz).
// Used ONLY at the v3 read boundary to convert those legacy frames to the seconds domain — it is a
// property of the frozen v3 wire format, not a live program rate. No other site may reference it.
inline constexpr double kLegacyV3NominalRate = 44100.0;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes);
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
//
@@ -432,7 +435,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes);
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Instance state (VST3 setState/getState) --------------------------------
//
+4 -2
View File
@@ -145,8 +145,10 @@ struct SampleLoop {
struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
int sampleRate = 44100; // frames per second (for reference; ratio is
// note-relative, so rate cancels for repitch)
int sampleRate = 0; // frames per second (for reference; ratio is
// note-relative, so rate cancels for repitch).
// 0 is explicitly invalid — every consumer must
// receive a real rate before use.
int rootNote = 60; // MIDI note recorded at (plays at unity here)
SampleLoop loop; // sustain loop, if any
// Initial read position (frame offset) a voice starts playback at — frame 0 by
+74 -56
View File
@@ -262,12 +262,6 @@ static void testBuildKeymapSingleFullZone() {
CHECK(km.resolve(127, 100).matched);
}
static void testBuildKeymapRateDefault() {
// A zero/invalid rate defaults to 44100 rather than producing a divide-by-zero-shaped
// sample rate downstream.
const Keymap km = buildTier0Keymap({0.1f}, 0, 60, SampleLoop{});
CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 44100);
}
// --- selection state (setState/getState) --------------------------------------
@@ -279,6 +273,7 @@ static void testSelectionStateRoundTrip() {
CHECK(deserializeSelection(bytes) == id);
}
static void testSelectionStateEmptyId() {
const std::vector<std::uint8_t> bytes = serializeSelection("");
CHECK(bytes.size() == 4); // just the version tag
@@ -603,7 +598,7 @@ static void testPerformanceStateRoundTrip() {
m.zones.push_back(zone("kick", 36, 47)); // no override
m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override
const std::vector<std::uint8_t> bytes = serializePerformance(m);
const PerformanceMap back = deserializePerformance(bytes);
const PerformanceMap back = deserializePerformance(bytes, 44100.0);
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick");
CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47);
@@ -617,7 +612,7 @@ static void testPerformanceStateEmpty() {
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
CHECK(bytes.size() == 16);
CHECK(deserializePerformance(bytes).zones.empty());
CHECK(deserializePerformance(bytes, 44100.0).zones.empty());
}
static void testPerformanceStateLoopStartRoundTrip() {
@@ -630,7 +625,7 @@ static void testPerformanceStateLoopStartRoundTrip() {
m.zones.push_back(z);
// A second zone with NO overrides proves the optional tail is per-record.
m.zones.push_back(zone("kick", 0, 23));
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 64);
@@ -662,7 +657,7 @@ static void testPerformanceStateV1PayloadBackCompat() {
u32(10); // lowNote
u32(40); // highNote
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
const PerformanceMap back = deserializePerformance(b);
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
@@ -679,7 +674,7 @@ static void testComponentStateLoopStartRoundTrip() {
z.loopOverride = lp;
z.startPoint = 128;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
back.map.zones[0].loopOverride->start == 500 &&
@@ -691,25 +686,25 @@ static void testComponentStateLoopStartRoundTrip() {
static void testPerformanceStateV1BackCompat() {
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
const PerformanceMap back = deserializePerformance(v1);
const PerformanceMap back = deserializePerformance(v1, 44100.0);
CHECK(back.zones.size() == 1);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127);
CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value());
// A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection").
CHECK(deserializePerformance(serializeSelection("")).zones.empty());
CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty());
}
static void testPerformanceStateGarbage() {
// Unknown version / truncated / empty -> empty map (never throws).
CHECK(deserializePerformance({}).zones.empty());
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}).zones.empty()); // unknown version
CHECK(deserializePerformance({}, 44100.0).zones.empty());
CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version
// Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty.
std::vector<std::uint8_t> t;
t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2
t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1
// (no zone payload)
CHECK(deserializePerformance(t).zones.empty());
CHECK(deserializePerformance(t, 44100.0).zones.empty());
}
static void testPerformanceStateNegativeNotesRoundTrip() {
@@ -717,7 +712,7 @@ static void testPerformanceStateNegativeNotesRoundTrip() {
// a hand-set/legacy value round-trips without corruption (two's-complement on the wire).
PerformanceMap m;
m.zones.push_back(zone("s", 0, 127, /*override=*/0));
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 0);
}
@@ -730,7 +725,7 @@ static void testComponentStateRoundTrip() {
s.selectionId = "picked-capture";
s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt));
s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48));
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "picked-capture");
CHECK(back.map.zones.size() == 2);
CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" &&
@@ -744,7 +739,7 @@ static void testComponentStateSelectionOnlyNoZones() {
// (NOT synthesize a zone) — the default face is one capture, zones are opt-in.
ComponentState s;
s.selectionId = "just-a-pick";
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "just-a-pick");
CHECK(back.map.zones.empty());
}
@@ -752,7 +747,7 @@ static void testComponentStateSelectionOnlyNoZones() {
static void testComponentStateEmptyIsEmpty() {
// No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample.
const ComponentState s; // selectionId "", empty map
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId.empty());
CHECK(back.map.zones.empty());
}
@@ -761,13 +756,13 @@ static void testComponentStateV1BackCompat() {
// A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick
// survives as BOTH the selection and a one-zone map.
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-id");
const ComponentState back = deserializeComponentState(v1);
const ComponentState back = deserializeComponentState(v1, 44100.0);
CHECK(back.selectionId == "legacy-id");
CHECK(back.map.zones.size() == 1);
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" &&
back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127);
// A v1 blob with an EMPTY id -> empty state (no selection, no zone).
const ComponentState empty = deserializeComponentState(serializeSelection(""));
const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0);
CHECK(empty.selectionId.empty() && empty.map.zones.empty());
}
@@ -777,7 +772,7 @@ static void testComponentStateV2BackCompat() {
PerformanceMap m;
m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt));
const std::vector<std::uint8_t> v2 = serializePerformance(m);
const ComponentState back = deserializeComponentState(v2);
const ComponentState back = deserializeComponentState(v2, 44100.0);
CHECK(back.selectionId.empty());
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" &&
back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24);
@@ -785,17 +780,17 @@ static void testComponentStateV2BackCompat() {
static void testComponentStateGarbage() {
// Empty / unknown version -> empty (never throws across the host).
CHECK(deserializeComponentState({}).selectionId.empty());
CHECK(deserializeComponentState({}).map.zones.empty());
CHECK(deserializeComponentState({}, 44100.0).selectionId.empty());
CHECK(deserializeComponentState({}, 44100.0).map.zones.empty());
const std::vector<std::uint8_t> unknown{0xAA, 0xBB, 0xCC, 0xDD};
CHECK(deserializeComponentState(unknown).map.zones.empty());
CHECK(deserializeComponentState(unknown).selectionId.empty());
CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty());
CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty());
// A v3 header claiming a longer id than the blob holds -> empty (bounded read).
std::vector<std::uint8_t> t;
t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3
t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent)
CHECK(deserializeComponentState(t).selectionId.empty());
CHECK(deserializeComponentState(t).map.zones.empty());
CHECK(deserializeComponentState(t, 44100.0).selectionId.empty());
CHECK(deserializeComponentState(t, 44100.0).map.zones.empty());
}
// --- S7: extractChannel / decodeChannels (cross-mode channel policy) ----------
@@ -887,7 +882,7 @@ static void testComponentStateV4RoundTripStereo() {
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
@@ -897,14 +892,14 @@ static void testComponentStateV4RoundTripMono() {
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Mono;
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.selectionId == "pick");
CHECK(back.channelMode == ChannelMode::Mono);
}
static void testComponentStateV4DefaultIsMono() {
// A default-constructed state serializes with mono and restores mono (preserves behavior).
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
@@ -918,7 +913,7 @@ static void testComponentStateV3LiftsToMono() {
v3.push_back(static_cast<std::uint8_t>(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0);
v3.insert(v3.end(), id.begin(), id.end());
v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v3);
const ComponentState back = deserializeComponentState(v3, 44100.0);
CHECK(back.selectionId == "legacy");
CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default
CHECK(back.map.zones.empty());
@@ -926,17 +921,17 @@ static void testComponentStateV3LiftsToMono() {
static void testComponentStateV1V2LiftToMono() {
// The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read.
const ComponentState v1 = deserializeComponentState(serializeSelection("old"));
const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0);
CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old");
PerformanceMap m; m.zones.push_back(zone("s", 12, 24));
const ComponentState v2 = deserializeComponentState(serializePerformance(m));
const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0);
CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1);
}
static void testComponentStateV4TruncatedModeByte() {
// A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds.
std::vector<std::uint8_t> t{4, 0, 0, 0}; // version 4, nothing after
const ComponentState back = deserializeComponentState(t);
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.channelMode == ChannelMode::Mono);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
@@ -959,7 +954,7 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
s.map.zones.push_back(z0);
s.map.zones.push_back(z1);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 2);
@@ -989,7 +984,7 @@ static void testComponentStateV5MarkerRoundTrip() {
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "pick");
@@ -999,7 +994,7 @@ static void testComponentStateV5MarkerRoundTrip() {
static void testComponentStateDefaultMarkerIsZero() {
// A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
// assign (generation >= 1) must not be swallowed by a non-zero default.
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0);
}
@@ -1015,7 +1010,7 @@ static void testComponentStateV4LiftsMarkerToZero() {
v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
v4.insert(v4.end(), id.begin(), id.end());
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v4);
const ComponentState back = deserializeComponentState(v4, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored
CHECK(back.selectionId == "saved");
@@ -1026,7 +1021,7 @@ static void testComponentStateV5TruncatedMarker() {
// A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
// mono + marker 0 default holds (bounded read, never throws across the host).
std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes
const ComponentState back = deserializeComponentState(t);
const ComponentState back = deserializeComponentState(t, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
@@ -1059,7 +1054,7 @@ static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() {
z.play.pitchEnv.peakSemitones = 12.5;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker
CHECK(back.selectionId == "pick");
@@ -1133,7 +1128,7 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
const std::vector<std::uint8_t> payload = handBuildV3PayloadOneZone("zv3");
v4.insert(v4.end(), payload.begin(), payload.end());
const ComponentState back = deserializeComponentState(v4);
const ComponentState back = deserializeComponentState(v4, 44100.0);
CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored
CHECK(back.selectionId == "s15saved");
@@ -1143,8 +1138,8 @@ static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() {
CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70);
const ZonePlaySeconds& p = back.map.zones[0].play;
CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope
// Legacy v3 wall-clock frames (44.1k-nominal) convert to seconds at the v3 authoring rate.
CHECK(approx(p.adsr.holdSeconds, 2048.0 / kLegacyV3NominalRate));
// Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here).
CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0));
CHECK(p.trigger.lengthFraction == 0.5);
CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is
CHECK(p.pitchEngine == PitchEngine::Varispeed);
@@ -1169,7 +1164,7 @@ static void testPlayParamsRoundTrip() {
z.play.pitchEnv.decaySeconds = 0.011;
z.play.pitchEnv.peakSemitones = -7.5;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const ZonePlaySeconds& p = back.zones[0].play;
@@ -1196,7 +1191,7 @@ static void testPlayParamsComposeWithLoopStart() {
z.play.adsr.holdSeconds = 0.0225;
z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].loopOverride.has_value() &&
@@ -1227,7 +1222,7 @@ static void testPlayParamsV2BackCompatLiftsToDefaults() {
b.push_back(0); // hasRootOverride = 0
b.push_back(0); // hasLoopOverride = 0
b.push_back(0); // hasStartPoint = 0 (record ends here in v2)
const PerformanceMap back = deserializePerformance(b);
const PerformanceMap back = deserializePerformance(b, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
CHECK(back.zones[0].sampleId == "old");
@@ -1249,7 +1244,7 @@ static void testPlayParamsThroughComponentEnvelope() {
z.play.trigger.lengthFraction = 0.9;
z.play.pitchEngine = PitchEngine::Varispeed;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.map.zones.size() == 1);
if (back.map.zones.size() != 1) return;
@@ -1276,7 +1271,7 @@ static void testFullAdsrSecondsRoundTrip() {
z.play.adsr.releaseSeconds = 0.2;
z.play.pitchEngine = PitchEngine::Preserve;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
@@ -1300,18 +1295,41 @@ static void testV3BlobLiftsAdsrToSecondsDefaults() {
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);
const PerformanceMap back = deserializePerformance(blob, 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
// hold converts from the v3 record's 44.1k-nominal frames; A/D/S/R lift to the seconds defaults.
CHECK(approx(a.holdSeconds, 2048.0 / kLegacyV3NominalRate)); // from the hand-built v3 record
// hold converts from the v3 record's frames at the passed project rate (44100.0 here).
CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds)
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060
}
// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the
// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000.
static void testV3BlobLiftsAdsrAt96k() {
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("old96k");
blob.insert(blob.end(), payload.begin(), payload.end());
const PerformanceMap back = deserializePerformance(blob, 96000.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) return;
const AdsrSeconds& a = back.zones[0].play.adsr;
// 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100).
CHECK(approx(a.holdSeconds, 2048.0 / 96000.0));
CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds);
CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel);
CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}
// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone
// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate));
// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates.
@@ -1324,7 +1342,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
{
PerformanceMap m;
m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds)
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
@@ -1348,7 +1366,7 @@ static void testKeymapBuildResolvesSecondsToFramesAtEachRate() {
z.play.adsr.sustainLevel = 0.7;
z.play.adsr.releaseSeconds = 0.2;
m.zones.push_back(z);
const PerformanceMap back = deserializePerformance(serializePerformance(m));
const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0);
CHECK(back.zones.size() == 1);
if (back.zones.size() != 1) continue;
ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60;
@@ -1400,7 +1418,6 @@ int main() {
testDownmixThreeChannelAverages();
testDownmixDegenerate();
testBuildKeymapSingleFullZone();
testBuildKeymapRateDefault();
testSelectionStateRoundTrip();
testSelectionStateEmptyId();
testSelectionStateWrongVersion();
@@ -1433,6 +1450,7 @@ int main() {
testPlayParamsThroughComponentEnvelope();
testFullAdsrSecondsRoundTrip();
testV3BlobLiftsAdsrToSecondsDefaults();
testV3BlobLiftsAdsrAt96k();
testKeymapBuildResolvesSecondsToFramesAtEachRate();
testBuildTier0KeymapResolvesSecondsAt48k();
testComponentStateRoundTrip();