// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / // vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. #include "sample_map.h" #include // std::min #include // assert #include // std::isfinite (v8 master-gain validation) #include // std::memcpy #include // std::move #include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap namespace reasampler { namespace { // Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank // stores loop points as an optional LoopPoints (both-or-neither); the core wants a // SampleLoop with an explicit hasLoop. Absent -> no loop. SampleLoop loopFromSample(const Sample& s) { SampleLoop out; if (s.loop) { out.hasLoop = true; out.start = s.loop->start; out.end = s.loop->end; } return out; } // A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C // (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on // C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4). SelectedSample distill(const Sample& s) { SelectedSample out; out.relativePath = s.relativePath; out.rootNote = s.rootNote ? *s.rootNote : 60; out.loop = loopFromSample(s); return out; } } // namespace std::optional selectSample(const std::string& banksJson, const std::string& sampleId) { // POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- // circuit before parsing — no stored id resolves to nothing to play by design. if (sampleId.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt; std::optional book = BankBook::deserialize(banksJson); if (!book) return std::nullopt; // malformed -> nothing to play (never throw) // Search every bank (pool first, then named — banks() is ordinal order) for the // stored id. A sample lives in exactly one bank, so first hit wins. for (const Bank& b : book->banks()) { if (const Sample* s = b.index.query(sampleId)) { return distill(*s); } } // A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: // the editor reflects the missing pick with its empty state rather than masking it. return std::nullopt; } std::vector listSamples(const std::string& banksJson) { std::vector out; if (banksJson.empty()) return out; std::optional book = BankBook::deserialize(banksJson); if (!book) return out; for (const Bank& b : book->banks()) { for (const Sample& s : b.index.all()) { out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id}); } } return out; } std::vector listBanks(const std::string& banksJson) { std::vector out; if (banksJson.empty()) return out; std::optional book = BankBook::deserialize(banksJson); if (!book) return out; for (const Bank& b : book->banks()) { out.push_back(BankChoice{b.id, b.displayName}); } return out; } std::vector downmixToMono(const std::vector& interleaved, int channelCount) { std::vector out; if (channelCount <= 0 || interleaved.empty()) return out; const std::size_t stride = static_cast(channelCount); const std::size_t frames = interleaved.size() / stride; out.resize(frames); const double inv = 1.0 / static_cast(channelCount); for (std::size_t f = 0; f < frames; ++f) { double acc = 0.0; const std::size_t base = f * stride; for (std::size_t c = 0; c < stride; ++c) { acc += static_cast(interleaved[base + c]); } out[f] = static_cast(acc * inv); } return out; } std::vector extractChannel(const std::vector& interleaved, int channelCount, int which) { std::vector out; if (channelCount <= 0 || interleaved.empty()) return out; const std::size_t stride = static_cast(channelCount); // Clamp the requested channel into the source's range: a channel past the last one reads // the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono). std::size_t ch = which < 0 ? 0 : static_cast(which); if (ch >= stride) ch = stride - 1; const std::size_t frames = interleaved.size() / stride; out.resize(frames); for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch]; return out; } DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate) { assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); DecodedZonePcm out; 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); return out; // framesR stays empty } // STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 // duplicated when the source is mono (dual-mono, centered). extractChannel clamps the // out-of-range channel request to the last channel, so a mono source yields L == R. out.monoFrames = extractChannel(interleaved, sourceChannels, 0); out.framesR = extractChannel(interleaved, sourceChannels, 1); return out; } 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. 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; return static_cast(f + 0.5); }; ZonePlayParams out; out.playMode = stored.playMode; out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds); out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds); out.trigger = stored.trigger; // source-frame / fraction, unchanged out.pitchEngine = stored.pitchEngine; out.pitchEnv.enabled = stored.pitchEnv.enabled; out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds); out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds); out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time return out; } 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 // mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). if (!framesR.empty() && framesR.size() == data.frames.size()) { data.framesR = std::move(framesR); } 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. data.play = resolvePlay(play, data.sampleRate); return Keymap::singleSampleChromatic(std::move(data)); } // --- Performance map --------------------------------------------------------- ResolvedPerformance resolvePerformance(const std::string& banksJson, const PerformanceMap& map) { ResolvedPerformance out; if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0) if (banksJson.empty()) return out; // no bank -> nothing resolves std::optional book = BankBook::deserialize(banksJson); if (!book) return out; // malformed -> nothing (never throw) for (const PerformanceZone& z : map.zones) { // Look the id up across every bank (pool + named) — a sample lives in exactly // one bank, so first hit wins. const Sample* found = nullptr; for (const Bank& b : book->banks()) { if (const Sample* s = b.index.query(z.sampleId)) { found = s; break; } } if (!found) { // STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). out.droppedSampleIds.push_back(z.sampleId); continue; } ResolvedZone rz; rz.relativePath = found->relativePath; rz.lowNote = z.lowNote; rz.highNote = z.highNote; // Effective root: override beats bank intrinsic beats middle-C default. rz.rootNote = z.rootOverride ? *z.rootOverride : (found->rootNote ? *found->rootNote : 60); // S-VIEW-6: the key-tracking scalar is instrument state (not a bank fact) — carried // straight through to the resolved zone and applied in the repitch math at play time. rz.keyTrack = z.keyTrack; // S-VIEW-9: the velocity->amp curve is likewise instrument state — carried through and // eval'd at Voice::start to set the voice's amp gain from the note-on velocity. rz.velocityCurve = z.velocityCurve; // Effective loop / start (S11): the instrument's per-zone override wins over the // bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is // never mutated — this only shapes what the core plays for THIS instance (D-B). rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found); rz.startFrame = z.startPoint ? *z.startPoint : 0; // S15/S16 per-zone play params (SECONDS) carry through unchanged (they are instrument // state, not resolved against the bank); buildZonedKeymap resolves them to frames. rz.play = z.play; out.zones.push_back(std::move(rz)); } return out; } bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) { if (selectedId.empty() || map.zones.empty()) return false; for (const PerformanceZone& z : map.zones) { // An authored key range marks Zone-view intent — first-match order is load-bearing // there, so the map is left exactly as authored. if (z.lowNote != 0 || z.highNote != 127) return false; } // Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first // zone bound to the selection (preserving its params); drop the stale shadowers. // Decide BEFORE mutating so the no-change path leaves the map bit-identical. std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection for (std::size_t i = 0; i < map.zones.size(); ++i) { if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; } } const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u; if (keptCount == map.zones.size()) return false; // one zone, already the selection's if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]); map.zones.resize(keptCount); return true; } Keymap buildZonedKeymap(const std::vector& zones, const std::vector& decoded) { Keymap km; const std::size_t n = std::min(zones.size(), decoded.size()); for (std::size_t i = 0; i < n; ++i) { // An unreadable/empty WAV drops just this zone (not the whole map). if (decoded[i].monoFrames.empty()) continue; SampleData data; data.frames = decoded[i].monoFrames; // Carry the second channel only when it length-matches channel 0 (channelCount() // enforces the same rule; a mismatched pair falls back to mono rather than half-play). if (!decoded[i].framesR.empty() && decoded[i].framesR.size() == data.frames.size()) { data.framesR = decoded[i].framesR; } 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) // Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's // actual rate; source-timeline params (trigger %-length + fades, start) carry through. data.play = resolvePlay(zones[i].play, data.sampleRate); const std::size_t sampleIndex = km.samples.size(); km.samples.push_back(std::move(data)); KeyZone zone; zone.lowNote = zones[i].lowNote; zone.highNote = zones[i].highNote; zone.rootNote = zones[i].rootNote; zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start zone.sampleIndex = sampleIndex; km.zones.push_back(zone); } return km; // empty zones in -> empty Keymap (silence) } // --- Performance-map instance state (setState/getState) ----------------------- namespace { void putU32le(std::vector& out, std::uint32_t v) { out.push_back(static_cast(v & 0xFF)); out.push_back(static_cast((v >> 8) & 0xFF)); out.push_back(static_cast((v >> 16) & 0xFF)); out.push_back(static_cast((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& out, std::uint64_t v) { for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); } std::uint64_t asU64(std::int64_t v) { return static_cast(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& bytes; std::size_t pos = 0; bool ok = true; explicit ByteReader(const std::vector& b) : bytes(b) {} std::uint32_t u32() { if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } const std::uint32_t v = static_cast(bytes[pos]) | (static_cast(bytes[pos + 1]) << 8) | (static_cast(bytes[pos + 2]) << 16) | (static_cast(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(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(static_cast(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(bytes[pos + static_cast(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(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(bytes[pos]) | (static_cast(bytes[pos + 1]) << 8) | (static_cast(bytes[pos + 2]) << 16) | (static_cast(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& out, const PerformanceMap& map) { putU32le(out, kZonesFormatMarker); putU32le(out, kZonesPayloadVersion); putU32le(out, static_cast(map.zones.size())); for (const PerformanceZone& z : map.zones) { putU32le(out, static_cast(z.sampleId.size())); out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); putU32le(out, static_cast(static_cast(z.lowNote))); putU32le(out, static_cast(static_cast(z.highNote))); out.push_back(z.rootOverride ? 1 : 0); if (z.rootOverride) { putU32le(out, static_cast(static_cast(*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& pts = z.velocityCurve.points(); putU32le(out, static_cast(pts.size())); for (const reasampler::vst::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(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()) / 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 // 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 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(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(reasampler::vst::VelocityPoint{vel, amp}); } if (r.ok) z.velocityCurve = reasampler::vst::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 serializePerformance(const PerformanceMap& map) { std::vector out; putU32le(out, kPerformanceStateVersion); putZonesPayload(out, map); return out; } 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(); 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 serializeComponentState(const ComponentState& state) { std::vector 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(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 = vst::masterGainMaxLinear(); if (!std::isfinite(g) || g < 0.0) g = 1.0; if (g > maxLin) g = maxLin; putU64le(out, doubleToBits(g)); } // 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(state.selectionId.size())); out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); putZonesPayload(out, state.map); return out; } 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(); 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 != kSelectionZonesModeMarkerVelVoiceV7Version && version != kSelectionZonesModeMarkerVelV6Version) { return out; // unknown -> empty } // v6/v7/v8 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(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 == kComponentStateVersion) { const double g = bitsToDouble(r.u64()); if (!r.ok) return out; // truncated inside the gain double — unity holds (out already // carries mode/marker/velocity/voice fields from above) out.masterGainLinear = (std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9)) ? g : 1.0; } 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 serializeSelection(const std::string& sampleId) { std::vector out; out.resize(4 + sampleId.size()); const std::uint32_t v = kSelectionStateVersion; out[0] = static_cast(v & 0xFF); out[1] = static_cast((v >> 8) & 0xFF); out[2] = static_cast((v >> 16) & 0xFF); out[3] = static_cast((v >> 24) & 0xFF); std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); return out; } std::string deserializeSelection(const std::vector& bytes) { if (bytes.size() < 4) return {}; // no version tag -> no selection const std::uint32_t v = static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | (static_cast(bytes[2]) << 16) | (static_cast(bytes[3]) << 24); if (v != kSelectionStateVersion) return {}; // unknown version -> ignore return std::string(reinterpret_cast(bytes.data() + 4), bytes.size() - 4); } } // namespace reasampler