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