Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green

This commit is contained in:
2026-07-29 10:56:09 -04:00
parent 9d5783453c
commit ea86f540b8
37 changed files with 6202 additions and 5352 deletions
+5 -573
View File
@@ -1,19 +1,14 @@
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER /
// SWELL / vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
#include "core/instrument/map/sample_map.h"
#include <algorithm> // std::min
#include <cassert> // assert
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy
#include <utility> // std::move
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
namespace reasampler {
using instrument::engine::masterGainMaxLinear;
namespace reasampler::instrument::map {
namespace {
@@ -405,568 +400,5 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
return km; // empty zones in -> empty Keymap (silence)
}
// --- Performance-map instance state (setState/getState) -----------------------
namespace {
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
// two's-complement u64, mirroring the u32 signed-int idiom above).
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
}
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
std::uint64_t doubleToBits(double d) {
std::uint64_t bits;
std::memcpy(&bits, &d, sizeof(bits));
return bits;
}
double bitsToDouble(std::uint64_t bits) {
double d;
std::memcpy(&d, &bits, sizeof(d));
return d;
}
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
struct ByteReader {
const std::vector<std::uint8_t>& bytes;
std::size_t pos = 0;
bool ok = true;
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
std::uint32_t u32() {
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
pos += 4;
return v;
}
std::uint8_t u8() {
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
return bytes[pos++];
}
std::string str(std::uint32_t len) {
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
pos += len;
return s;
}
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
std::uint64_t u64() {
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
std::uint64_t v = 0;
for (int b = 0; b < 8; ++b)
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
pos += 8;
return v;
}
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
// as "no marker" and falls through to the (also-guarded) v1 count read.
std::uint32_t peekU32() const {
if (!ok || pos + 4 > bytes.size()) return 0;
return static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
}
};
// Append the zones payload — the shared body of the performance blob and the component blob,
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
// the zone count so any reader can detect the record shape independently of the envelope version
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putU32le(out, kZonesFormatMarker);
putU32le(out, kZonesPayloadVersion);
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
for (const PerformanceZone& z : map.zones) {
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
out.push_back(z.rootOverride ? 1 : 0);
if (z.rootOverride) {
putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
}
// S11 extension: loop override (hasLoop flag + start/end), then start point.
out.push_back(z.loopOverride ? 1 : 0);
if (z.loopOverride) {
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
putU64le(out, asU64(z.loopOverride->start));
putU64le(out, asU64(z.loopOverride->end));
}
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
// fraction. Order matches the header's v5 record spec.
const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
putU64le(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putU32le(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) {
putU64le(out, doubleToBits(p.velocity));
putU64le(out, doubleToBits(p.amp));
}
}
}
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
// 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.
// `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) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail
}
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
z.lowNote = r.i32();
z.highNote = r.i32();
const std::uint8_t hasOverride = r.u8();
if (hasOverride) z.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
z.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
// 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()) / 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()) / 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
// frames; read in the emit order.
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64();
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
// count can't trigger a giant allocation before the bounded reads fail — the loop still
// stops on r.ok, this only caps the speculative reserve.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts));
}
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z));
}
}
} // namespace
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
std::vector<std::uint8_t> out;
putU32le(out, kPerformanceStateVersion);
putZonesPayload(out, map);
return out;
}
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();
if (!r.ok) return map; // no version tag -> empty
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
// no length prefix). Lift it to one full-keyboard zone playing that id.
if (version == kSelectionStateVersion) {
const std::string id = deserializeSelection(bytes);
if (!id.empty()) {
PerformanceZone z;
z.sampleId = id;
z.lowNote = 0;
z.highNote = 127;
map.zones.push_back(std::move(z));
}
return map;
}
if (version != kPerformanceStateVersion) return map; // unknown -> empty
readZonesPayload(r, map, projectRate);
return map;
}
// --- Combined component state (v3, S10) --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out;
putU32le(out, kComponentStateVersion);
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
// stops at the mode byte is a strict prefix (see the v4 lift below).
putU64le(out, asU64(state.lastConsumedAssignGeneration));
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
out.push_back(state.previewVelocity);
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount;
out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear();
if (!std::isfinite(g) || g < 0.0) g = 1.0;
if (g > maxLin) g = maxLin;
putU64le(out, doubleToBits(g));
}
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
// channel count); 1 = the user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
// written), channelCount, displayName (length-prefixed; display-only).
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) {
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
putU64le(out, asU64(e.ref.loop.start));
putU64le(out, asU64(e.ref.loop.end));
putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
}
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
putU32le(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
putZonesPayload(out, state.map);
return out;
}
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();
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
// zone} so the old pick survives as BOTH the selection and a one-zone map.
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection.
if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes);
if (!out.selectionId.empty()) {
PerformanceZone z;
z.sampleId = out.selectionId;
z.lowNote = 0;
z.highNote = 127;
out.map.zones.push_back(std::move(z));
}
return out;
}
if (version == kPerformanceStateVersion) {
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).
if (version == kSelectionZonesV3Version) {
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, 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):
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
// defaults to 0, so a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
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, projectRate);
return out; // marker stays 0 (pre-S8/S9 reader)
}
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
// pre-S-VIEW-4 instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
out.lastConsumedAssignGeneration = r.i64();
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
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, projectRate);
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) {
return out; // unknown -> empty
}
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance.
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
out.lastConsumedAssignGeneration = r.i64();
if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds)
const std::uint8_t previewVel = r.u8();
if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds)
// Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a
// corrupt blob) falls back to the mid default rather than silencing the preview trigger.
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel
: kPreviewVelocityDefault;
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
const std::uint8_t mt = r.u8();
if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold)
// Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent
// for a corrupt blob) rather than clamping to an edge the user never chose.
out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount)
? static_cast<int>(vc)
: kDefaultVoiceCount;
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
}
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries
// mode/marker/velocity/voice fields from above; unity holds
out.masterGainLinear =
(std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9))
? g
: 1.0;
}
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
// un-touched default and the shell may auto-default it from the loaded capture.
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1);
}
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
if (version >= kSelectionZonesRefsV10Version) {
const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
SampleRefEntry e;
const std::uint32_t refIdLen = r.u32();
e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen);
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
// corrupt field must degrade to the field's default, never poison playback —
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
// to the middle-C default distill() uses; a negative channel count falls back
// to 0 = unknown (the GA auto-default then skips it).
const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0);
e.ref.loop.start = r.i64();
e.ref.loop.end = r.i64();
const std::int32_t channels = r.i32();
e.ref.channelCount = channels >= 0 ? channels : 0;
const std::uint32_t nameLen = r.u32();
e.displayName = r.str(nameLen);
if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest
out.sampleRefs.push_back(std::move(e));
}
if (!r.ok) return out;
}
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
// EMPTY default holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen);
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
}
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, projectRate);
return out;
}
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
std::vector<std::uint8_t> out;
out.resize(4 + sampleId.size());
const std::uint32_t v = kSelectionStateVersion;
out[0] = static_cast<std::uint8_t>(v & 0xFF);
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
return out;
}
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < 4) return {}; // no version tag -> no selection
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
(static_cast<std::uint32_t>(bytes[1]) << 8) |
(static_cast<std::uint32_t>(bytes[2]) << 16) |
(static_cast<std::uint32_t>(bytes[3]) << 24);
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
bytes.size() - 4);
}
} // namespace reasampler
} // namespace reasampler::instrument::map