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:
@@ -0,0 +1,508 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7)
|
||||
// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library +
|
||||
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
|
||||
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
|
||||
#include <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy (serializeSelection)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
using engine::masterGainMaxLinear;
|
||||
using reasampler::wire::ByteReader;
|
||||
using reasampler::wire::bitsToDouble;
|
||||
using reasampler::wire::doubleToBits;
|
||||
using reasampler::wire::putLE;
|
||||
|
||||
namespace {
|
||||
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// 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) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(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);
|
||||
putLE(out, asU64(z.loopOverride->start));
|
||||
putLE(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(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);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(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();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(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;
|
||||
putLE(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;
|
||||
putLE(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).
|
||||
putLE(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;
|
||||
putLE(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).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
|
||||
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
|
||||
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(e.ref.loop.start));
|
||||
putLE(out, asU64(e.ref.loop.end));
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||
putLE(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.
|
||||
putLE(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).
|
||||
putLE(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::instrument::map
|
||||
@@ -0,0 +1,326 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO
|
||||
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps.
|
||||
//
|
||||
// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11
|
||||
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's
|
||||
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
|
||||
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
|
||||
// so the payload and the instrument's reader can never drift. Housing it inside
|
||||
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
|
||||
// to serialize one preset blob; split out, both artifacts link the codec and only the
|
||||
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
|
||||
// value validation) — never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
|
||||
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||
// 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 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):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (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.
|
||||
// `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) -------------
|
||||
//
|
||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
||||
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
|
||||
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
||||
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
|
||||
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
|
||||
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
|
||||
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
||||
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
||||
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
||||
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
||||
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
||||
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
|
||||
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
|
||||
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
||||
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
|
||||
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
|
||||
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
|
||||
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
|
||||
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
||||
// older blob lifting to these plays byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
|
||||
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
|
||||
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
|
||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
||||
double masterGainLinear = 1.0;
|
||||
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
||||
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
||||
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
|
||||
std::string instanceGuid;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
|
||||
// after the refs table). Mirrors the v10/v9/… series so the version branches in
|
||||
// deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
|
||||
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
||||
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
|
||||
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
|
||||
// lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
||||
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
|
||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
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.
|
||||
// `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) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
// The selected-sample id serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
|
||||
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -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
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler {
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map
|
||||
// re-namespaces in its own split wave (Q-W2v).
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in
|
||||
// instrument::map; the engine family stays in flat `reasampler` until its own wave).
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
@@ -413,302 +413,10 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||
// 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 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):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
// The ComponentState envelope + zones-payload binary codec (serializePerformance /
|
||||
// serializeComponentState / serializeSelection + the deserializers and every version
|
||||
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows
|
||||
// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the
|
||||
// split lets both artifacts share the codec while only the VST links the voice engine.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (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.
|
||||
// `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) -------------
|
||||
//
|
||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
//
|
||||
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
||||
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
|
||||
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
||||
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
|
||||
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
|
||||
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
|
||||
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
||||
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
||||
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
||||
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
||||
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
||||
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
|
||||
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
|
||||
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
||||
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
|
||||
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
|
||||
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
|
||||
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
|
||||
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
||||
// older blob lifting to these plays byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
|
||||
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
|
||||
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
|
||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
||||
double masterGainLinear = 1.0;
|
||||
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
||||
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
||||
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
|
||||
std::string instanceGuid;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
|
||||
// after the refs table). Mirrors the v10/v9/… series so the version branches in
|
||||
// deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
|
||||
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
||||
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
|
||||
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
|
||||
// lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
||||
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
|
||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
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.
|
||||
// `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) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
// The selected-sample id serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
|
||||
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
Reference in New Issue
Block a user