From 082c9b82c2b17bb69860c09109603d7636dd21e7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 03:07:52 -0400 Subject: [PATCH] s12: thread project rate through v3 legacy lift; remove 44100 literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/vst/reasampler_processor.cpp | 6 +- src/vst/reasampler_processor.h | 5 +- src/vst/sample_map.cpp | 59 +++++++++----- src/vst/sample_map.h | 30 ++++--- src/vst/sampler_core.h | 6 +- tests/test_sample_map.cpp | 130 ++++++++++++++++++------------- 6 files changed, 144 insertions(+), 92 deletions(-) diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index c9baef0..c14f7ce 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -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 diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index b9c4299..fa7ff2e 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -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 --------------------------------------------- diff --git a/src/vst/sample_map.cpp b/src/vst/sample_map.cpp index c80fee5..760332e 100644 --- a/src/vst/sample_map.cpp +++ b/src/vst/sample_map.cpp @@ -4,6 +4,7 @@ #include "sample_map.h" #include // std::min +#include // assert #include // std::memcpy #include // std::move @@ -118,8 +119,10 @@ std::vector extractChannel(const std::vector& interlea DecodedZonePcm decodeChannels(const std::vector& 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(sampleRate) : 44100.0; + assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); + const double sr = sampleRate > 0 ? static_cast(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 frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector 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 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& 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& 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(r.i64()) / kLegacyV3NominalRate; + z.play.adsr.holdSeconds = static_cast(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(r.i64()) / kLegacyV3NominalRate; - z.play.pitchEnv.decaySeconds = static_cast(r.i64()) / kLegacyV3NominalRate; + z.play.pitchEnv.attackSeconds = static_cast(r.i64()) / liftRate; + z.play.pitchEnv.decaySeconds = static_cast(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 serializePerformance(const PerformanceMap& map) { return out; } -PerformanceMap deserializePerformance(const std::vector& bytes) { +PerformanceMap deserializePerformance(const std::vector& 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& bytes) { } if (version != kPerformanceStateVersion) return map; // unknown -> empty - readZonesPayload(r, map); + readZonesPayload(r, map, projectRate); return map; } @@ -526,7 +544,10 @@ std::vector serializeComponentState(const ComponentState& state) { return out; } -ComponentState deserializeComponentState(const std::vector& bytes) { +ComponentState deserializeComponentState(const std::vector& 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& 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& 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& 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& 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; } diff --git a/src/vst/sample_map.h b/src/vst/sample_map.h index 810999b..2bd5d79 100644 --- a/src/vst/sample_map.h +++ b/src/vst/sample_map.h @@ -260,7 +260,8 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson, // map). Empty zones in -> empty Keymap (silence). struct DecodedZonePcm { std::vector 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 framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, @@ -323,10 +324,10 @@ DecodedZonePcm decodeChannels(const std::vector& 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 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& 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& bytes, + double projectRate); // --- Combined component state (VST3 setState/getState, v3 — S10) ------------- // @@ -432,7 +435,10 @@ std::vector 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& 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& bytes, + double projectRate); // --- Instance state (VST3 setState/getState) -------------------------------- // diff --git a/src/vst/sampler_core.h b/src/vst/sampler_core.h index 98f826b..3027f78 100644 --- a/src/vst/sampler_core.h +++ b/src/vst/sampler_core.h @@ -145,8 +145,10 @@ struct SampleLoop { struct SampleData { std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector 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 diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 73609a2..f5e28fb 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -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 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 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 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 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 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 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 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 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 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(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 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(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 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 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 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 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 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();