Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 07:15:54 -04:00
parent a689fb75eb
commit 8d4ccbf841
61 changed files with 5416 additions and 8008 deletions
+226 -225
View File
@@ -1,5 +1,5 @@
// 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).
// component_state_io — the ComponentState envelope + params-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
// Every wire format is FROZEN — byte-identical across revisions.
#include "core/instrument/map/component_state_io.h"
@@ -11,7 +11,7 @@
#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)
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
namespace reasampler::instrument::map {
@@ -26,98 +26,134 @@ 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 (marker +
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
// marker precedes the zone count so any reader can detect record shape independent of the
// envelope version (see sample_map.h).
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)));
}
// 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));
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
// payload that carried at least one zone: the first zone's capture, which supersedes the
// envelope's selection id (see the adoption rule in the header).
struct PayloadRead {
InstrumentParams params;
std::string adoptedSampleId;
};
// Play params (PAYLOAD v5): always present. 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: the per-zone key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(z.keyTrack));
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as 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));
}
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// the two shapes cannot drift byte-for-byte.
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
out.push_back(p.rootOverride ? 1 : 0);
if (p.rootOverride) {
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
}
out.push_back(p.loopOverride ? 1 : 0);
if (p.loopOverride) {
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
putLE(out, asU64(p.loopOverride->start));
putLE(out, asU64(p.loopOverride->end));
}
out.push_back(p.startPoint ? 1 : 0);
if (p.startPoint) putLE(out, asU64(*p.startPoint));
}
// Append the params payload: marker + version + the single parameter record. Always emits
// the CURRENT payload version; the marker precedes the record so any reader detects the
// shape independent of the envelope version (see component_state_io.h).
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
putLE(out, kParamsFormatMarker);
putLE(out, kParamsPayloadVersion);
putOverrides(out, p);
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
const PlaySeconds& pp = p.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));
// Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(p.keyTrack));
// The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
// per point velocity + amp as doubles (endpoints included, so N >= 2).
const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(pt.amp));
}
}
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
// back-compat lift, overrides 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 seconds at the read boundary: seconds = frames
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
bool extended = false; // v2+: the 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
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// v8 single-record reader so the two can never disagree about field order.
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint
// invariant defensively; a truncated read leaves the flat default.
void readCurveTail(ByteReader& r, InstrumentParams& p) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
// can't trigger a giant allocation before the bounded reads fail.
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 i = 0; i < ptCount && r.ok; ++i) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(VelocityPoint{vel, amp});
}
const bool legacyV3Play = (pv == 3); // legacy 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+: per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
if (r.ok) {
p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
}
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
// no file and no bank entry.
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
// blobs carry seconds directly and need no rate.
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
PayloadRead out;
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
const std::uint32_t count = r.u32();
bool adopted = false;
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) lifts every zone to those defaults.
PerformanceZone z;
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
InstrumentParams p;
std::string sampleId;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
z.lowNote = r.i32();
z.highNote = r.i32();
sampleId = r.str(idLen);
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
r.i32(); // highNote
const std::uint8_t hasOverride = r.u8();
if (hasOverride) z.rootOverride = r.i32();
if (hasOverride) p.rootOverride = r.i32();
if (extended) {
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
@@ -125,113 +161,88 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
z.loopOverride = lp;
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64();
if (hasStart) p.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 `projectRate` to reach seconds.
// 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());
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
p.play.trigger.fadeInFrames = r.i64();
p.play.trigger.fadeOutFrames = r.i64();
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
p.play.pitchEnv.enabled = (r.u8() != 0);
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
p.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());
readSecondsPlayTail(r, p);
}
// PAYLOAD v6: 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.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
// read 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 hold (16 bytes/point) so a corrupt huge
// count can't trigger a giant allocation before the bounded reads fail.
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::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (curveTail) readCurveTail(r, p);
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// leave the seconds product defaults on p.play.
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
if (!adopted) {
out.params = std::move(p);
out.adoptedSampleId = std::move(sampleId);
adopted = true;
}
// 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 for a LEGACY v3 payload; unused for
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
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 original 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;
// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
// zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
std::uint32_t pv = 0; // 0 = v1, no marker
if (r.peekU32() == kParamsFormatMarker) {
r.u32(); // consume the marker
pv = r.u32(); // payload version
}
if (version != kPerformanceStateVersion) return map; // unknown -> empty
if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
readZonesPayload(r, map, projectRate);
return map;
PayloadRead out;
InstrumentParams& p = out.params;
const std::uint8_t hasRoot = r.u8();
if (hasRoot) p.rootOverride = r.i32();
const std::uint8_t hasLoop = r.u8();
if (hasLoop) {
SampleLoop lp;
lp.hasLoop = (r.u8() != 0);
lp.start = r.i64();
lp.end = r.i64();
p.loopOverride = lp;
}
const std::uint8_t hasStart = r.u8();
if (hasStart) p.startPoint = r.i64();
readSecondsPlayTail(r, p);
p.keyTrack = bitsToDouble(r.u64());
readCurveTail(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
return out;
}
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
// supersedes the envelope's selection id) lives here, once.
void applyPayload(ComponentState& out, PayloadRead read) {
out.params = std::move(read.params);
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
}
} // namespace
// --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
@@ -268,7 +279,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
// 1 = user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
// to here. Wire shape per kSelectionRefsV10Version: 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()));
@@ -286,75 +297,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
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,
// v11 envelope addition (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 —
// Length-prefixed selection id (it precedes the params 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);
putParamsPayload(out, state.params);
return out;
}
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// v5+. See readZonesPayload for the guard.
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
ComponentState out;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
// * v1 (original 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 (zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection.
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
// loaded capture with default parameters.
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
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
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
return out; // channelMode stays Mono
}
// BACK-COMPAT: a v3 blob ({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) {
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
// length + id + payload starts right after the version tag (no mode byte).
if (version == kSelectionV3Version) {
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);
applyPayload(out, readParamsPayload(r, projectRate));
return out; // channelMode stays Mono, marker stays 0
}
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
// a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) {
if (version == kSelectionModeV4Version) {
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);
applyPayload(out, readParamsPayload(r, projectRate));
return out; // marker stays 0
}
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
// already-saved instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) {
if (version == kSelectionModeMarkerV5Version) {
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;
@@ -363,21 +364,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
applyPayload(out, readParamsPayload(r, projectRate));
return out; // previewVelocity stays at the mid default
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) {
version != kSelectionRefsV10Version &&
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionModeMarkerVelVoiceGainV8Version &&
version != kSelectionModeMarkerVelVoiceV7Version &&
version != kSelectionModeMarkerVelV6Version) {
return out; // unknown -> empty
}
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
// preview velocity. A non-{0,1} mode byte treats 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;
@@ -392,7 +393,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
: kPreviewVelocityDefault;
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
const std::uint8_t mt = r.u8();
@@ -408,7 +409,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
// than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
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
@@ -420,7 +421,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// v9: 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 untouched default and the shell may auto-default it from the loaded capture.
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1);
@@ -428,8 +429,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// v10: 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) {
// drops the rest (the selection/params behind it are unreadable anyway).
if (version >= kSelectionRefsV10Version) {
const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
SampleRefEntry e;
@@ -457,7 +458,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
// v11: 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) {
if (version >= kSelectionRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen);
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
@@ -465,7 +466,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
applyPayload(out, readParamsPayload(r, projectRate));
return out;
}
+127 -164
View File
@@ -1,125 +1,91 @@
#pragma once
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
// instrument's processor reads/writes it at setState/getState, and the extension's
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
// payload and the instrument's reader can never drift — without the extension having to
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
// engine.
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
// payload v1..v7) must be preserved exactly.
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v8) must be preserved exactly.
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
namespace reasampler::instrument::map {
// --- Performance-map instance state (VST3 setState/getState) -----------------
// --- The instance's parameter payload ----------------------------------------
//
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
// of truncation/wrong-version (bounded reads, never throws across the host).
// The one parameter set is the instrument's OWN state, serialized to the VST3
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
// OWN version, so its record can grow without bumping the envelope version. Payload
// extensions and envelope-field additions stay on independent axes that can never collide
// on one version number.
//
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
// carries its OWN version, so the per-zone record can grow without bumping the envelope
// version. Zone-record extensions and envelope-field additions stay on independent axes
// that can never collide on one version number.
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
// case. Their record shapes, in order:
// * v1 (original, no marker): 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, 4-byte LE
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
// is v1.
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
// (int64). The marker lets the reader detect record shape independent of the envelope.
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
// LE pitchEnv.attackFrames + 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, pitch env disabled) — deliberate for
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
// intentionally not read. Keymap builders resolve 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 original single-selection format: version tag 1 + id
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
// truncated/unknown/empty blob deserializes to an EMPTY map.
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
// threaded into the v3 lift path at read time (a parameter, no baked constant).
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
// tier-0 seconds defaults (0.003/0/1.0/0.060).
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
// frames tail) was never shipped and is intentionally not read.
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
// (soft hits play louder than under the old linear velocity/127 map).
//
// These two functions serialize the ZONES only; the instrument's full component state is
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
// SINGLE record with no count, no key range and no sample id (the envelope's selection id is
// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte
// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set);
// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim
// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7.
//
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker. serializePerformance and
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
// tail, 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 no legitimate zone count
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). 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() (Daniel-approved).
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
// hits play LOUDER than under the old 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; // + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
// so the seconds domain is consistent across both paths. No constant is baked in.
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the 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+) -------------
//
// 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 an instance with NO pick and NO
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
// sample #1.
// --- Combined component state (VST3 setState/getState) -----------------------
//
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
@@ -129,51 +95,53 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
// kSelectionRefsV10Version below); the INSTANCE GUID (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); 4-byte LE selection-id length + id bytes; then the
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
// version). The instance guid is the only 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.
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
//
// 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.
//
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
// loaded capture, and 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, which the shell
// re-mints on first publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
// loaded capture and an EMPTY sample-refs table, which the shell lifts once via the
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
// publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
// * v7 blob -> unity master gain.
// * v6 blob -> voice defaults.
// * v5 blob -> no velocity byte.
// * v4 blob -> no marker.
// * v3 blob -> no channel mode.
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
//
// WHY THE MARKER PERSISTS. The last-consumed assignment generation 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
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
//
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation 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, never written
// to the bank — the extension owns the assign_request key; the instrument only tracks what
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
// with no velocity byte lifts to this, audible-but-not-hot.
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
// no velocity byte lifts to this, audible-but-not-hot.
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
std::string selectionId; // the loaded capture; "" = no pick
InstrumentParams params; // the ONE parameter set governing it
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
// 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
@@ -181,26 +149,25 @@ struct ComponentState {
// choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
// 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.
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
// the Sample-view preview button retains the user's chosen strike velocity across saves.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
// exactly, so an older blob lifting to these plays byte-identically.
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
// reproduce pre-voice-system 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
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
// unity reproduces pre-master-gain output byte-identically.
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
// voice sum — never per voice. Default unity reproduces pre-master-gain output
// byte-identically.
double masterGainLinear = 1.0;
// Self-contained playback: 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).
// bank sample this instance plays (see the SampleRefs block in sample_map.h). 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;
// 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
@@ -214,7 +181,7 @@ inline constexpr std::uint32_t kComponentStateVersion = 11;
// 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;
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
// 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:
@@ -222,36 +189,36 @@ inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
// length + bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
// Everything through the 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;
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
// 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;
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
// Selection + params + 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;
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
// Selection + params + 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;
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
// Selection + params + 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;
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
// 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;
// Selection + params + channel mode, no consumed marker. Retained so
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
// blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
// blob to {mono, selection, params}.
inline constexpr std::uint32_t kSelectionV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
@@ -266,16 +233,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// --- Instance state (VST3 setState/getState) --------------------------------
//
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
// the instrument, never written back to the bank) — a single string id. serialize/
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
// without breaking already-saved instances.
// The original v1 instance state was which bank sample it plays — a single string id.
//
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
// version/empty blob by returning "" (no 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.
// for the v1 back-compat lift in deserializeComponentState.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
@@ -286,5 +250,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::instrument::map
+61 -158
View File
@@ -3,7 +3,7 @@
#include "core/instrument/map/sample_map.h"
#include <algorithm> // std::min
#include <algorithm> // std::remove_if
#include <cassert> // assert
#include <utility> // std::move
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
return out;
}
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
ResolvedZone rz;
rz.relativePath = ref.relativePath;
rz.lowNote = z.lowNote;
rz.highNote = z.highNote;
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// Key tracking + velocity curve are instrument state — carried straight through.
rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve;
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
// (start). The bank is never mutated.
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
rz.startFrame = z.startPoint ? *z.startPoint : 0;
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
return rz;
}
} // namespace
std::optional<SelectedSample> selectSample(const std::string& banksJson,
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
return nullptr;
}
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map) {
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
std::vector<std::string> ids;
const auto addUnique = [&ids](const std::string& id) {
if (id.empty()) return;
for (const std::string& have : ids) {
if (have == id) return;
}
ids.push_back(id);
};
addUnique(selectionId);
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
if (!selectionId.empty()) ids.push_back(selectionId);
return ids;
}
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
return out;
}
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
DecodedPcm out;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
@@ -230,7 +202,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
// fades) carry through untouched, already frames/fractions.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
@@ -240,7 +212,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
ZonePlayParams out;
PlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
@@ -256,130 +228,61 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
// --- The one parameter set ----------------------------------------------------
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
ResolvedCapture rs;
rs.relativePath = ref.relativePath;
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
// Key tracking + velocity curve are instrument state — carried straight through.
rs.keyTrack = params.keyTrack;
rs.velocityCurve = params.velocityCurve;
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
// The bank is never mutated.
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
rs.startFrame = params.startPoint ? *params.startPoint : 0;
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
return rs;
}
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
const std::string& selectionId,
const InstrumentParams& params) {
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
if (!sel) return std::nullopt;
return resolveCapture(*sel, params);
}
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
const std::string& selectionId,
const InstrumentParams& params) {
const SelectedSample* ref = findRef(refs, selectionId);
if (ref == nullptr) return std::nullopt;
return resolveCapture(*ref, params);
}
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
if (!framesR.empty() && framesR.size() == data.frames.size()) {
data.framesR = std::move(framesR);
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
assert(decoded.sampleRate > 0 &&
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
data.frames = std::move(decoded.monoFrames);
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
data.framesR = std::move(decoded.framesR);
}
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
data.sampleRate = sampleRate;
data.rootNote = rootNote;
data.loop = loop;
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
data.play = resolvePlay(play, data.sampleRate);
return Keymap::singleSampleChromatic(std::move(data));
data.sampleRate = decoded.sampleRate;
data.rootNote = resolved.rootNote;
data.loop = resolved.loop;
data.startFrame = resolved.startFrame;
data.keyTrack = resolved.keyTrack;
data.velocityCurve = resolved.velocityCurve;
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(resolved.play, data.sampleRate);
return data;
}
// --- Performance map ---------------------------------------------------------
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map) {
ResolvedPerformance out;
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
if (banksJson.empty()) return out; // no bank -> nothing resolves
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) {
// A sample lives in exactly one bank, so first hit wins.
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(z.sampleId)) {
found = s;
break;
}
}
if (!found) {
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
continue;
}
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
// fold — so the bank path and refs path resolve identically.
out.zones.push_back(foldZone(z, distill(*found)));
}
return out;
}
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map) {
ResolvedPerformance out;
for (const PerformanceZone& z : map.zones) {
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r));
} else {
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId);
}
}
return out;
}
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
if (selectedId.empty() || map.zones.empty()) return false;
for (const PerformanceZone& z : map.zones) {
// An authored key range marks Zone-view intent — first-match order is load-bearing
// there, so the map is left exactly as authored.
if (z.lowNote != 0 || z.highNote != 127) return false;
}
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
// zone bound to the selection (preserving its params); drop the stale shadowers.
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
for (std::size_t i = 0; i < map.zones.size(); ++i) {
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
}
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
map.zones.resize(keptCount);
return true;
}
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded) {
Keymap km;
const std::size_t n = std::min(zones.size(), decoded.size());
for (std::size_t i = 0; i < n; ++i) {
// An unreadable/empty WAV drops just this zone (not the whole map).
if (decoded[i].monoFrames.empty()) continue;
SampleData data;
data.frames = decoded[i].monoFrames;
// Carry the second channel only when it length-matches channel 0 (channelCount()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded[i].framesR.empty() &&
decoded[i].framesR.size() == data.frames.size()) {
data.framesR = decoded[i].framesR;
}
assert(decoded[i].sampleRate > 0 &&
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
data.sampleRate = decoded[i].sampleRate;
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data));
KeyZone zone;
zone.lowNote = zones[i].lowNote;
zone.highNote = zones[i].highNote;
zone.rootNote = zones[i].rootNote;
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
zone.sampleIndex = sampleIndex;
km.zones.push_back(zone);
}
return km; // empty zones in -> empty Keymap (silence)
}
} // namespace reasampler::instrument::map
+88 -144
View File
@@ -1,10 +1,11 @@
#pragma once
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
// here. Links bank_book, wav_codec, and sampler_core (all pure).
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
// NOT the voice engine: the build's product is plain SampleData.
#include <cstdint>
#include <optional>
@@ -12,7 +13,7 @@
#include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map {
@@ -29,7 +30,7 @@ struct SelectedSample {
int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // hasLoop=false when the bank left it empty
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
// the GA channel-mode auto-default skips it
// the channel-mode auto-default skips it
};
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
@@ -60,8 +61,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
// Consequence: a sample deleted from the bank no longer silences an instance that carries
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// the file yields the defined no-play).
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
@@ -74,10 +73,9 @@ using SampleRefs = std::vector<SampleRefEntry>;
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
// Every bank sample id this instance plays: the selection (when set) + each zone's
// sampleId, de-duplicated, selection first then map order.
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// Every bank sample id this instance plays. One capture = at most one id; the list form is
// kept because the refs-table helpers below are id-set operations.
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
@@ -123,10 +121,10 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// --- Stored (wall-clock SECONDS) play params ----------------------------------
//
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
// keymap build. Quantities anchored to the source file's timeline (start point, loop
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
// unchanged (TriggerParams reused verbatim).
// build. Quantities anchored to the source file's timeline (start point, loop points,
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
// (TriggerParams reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
@@ -162,10 +160,10 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak
};
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
struct ZonePlaySeconds {
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
// from the engine-facing PlayParams (frames).
struct PlaySeconds {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
@@ -173,46 +171,28 @@ struct ZonePlaySeconds {
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// picked single capture plays under the same default engine as a zone would. Resolves the
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- Performance map (the instrument's OWN state) ---------------
// --- The instrument's ONE parameter set (its OWN state) -----------------------
//
// The performance map is the keymap the user authors IN the instrument: several bank
// samples zoned across the keyboard, each with a key range and a root note. A performance
// choice, so it lives in the instrument (VST3 component state), never written back to the
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
// + this map into a sampler_core Keymap.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
// read position are facts about the file, but the instrument may override them per zone
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
// the effective ResolvedZone.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
int highNote = 127; // inclusive
// One loaded capture, one set of playback parameters governing it across the whole
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
// never written back to the bank. Pure value type: names no sample (the ComponentState's
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
// the pure build stitches the decoded frames + this set into one SampleData.
//
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
// read position are facts about the file, but the instrument may override them without
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
// ResolvedCapture.
struct InstrumentParams {
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
@@ -223,116 +203,80 @@ struct PerformanceZone {
double keyTrack = 1.0;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// in Voice::start.
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
// Voice::start.
VelocityCurve velocityCurve = VelocityCurve::flat();
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// to exactly these defaults on read.
ZonePlaySeconds play;
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
PlaySeconds play;
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution — first zone in order wins (mirrors the core's first-match
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
//
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
// control edit. Loading a different sample used to change only the selection id, leaving
// the previous sample's full-range zone in the map — and since zone resolution is
// first-match in order, that stale zone shadowed every later one forever: the engine kept
// playing the old sample while the editor drew the new one's zone. This function is called
// at every selection-change site so the zone the editor draws is the zone the engine plays.
//
// Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
// first-match order is load-bearing there — untouched, false (the Sample face never
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
// (params preserved); drop the rest. A selection with no zone yet empties the map.
// Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// an id) — this is the id resolved against the live bank.
struct ResolvedZone {
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
// (which holds optional overrides) — this is the parameter set folded against the capture.
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
PlaySeconds play; // stored SECONDS; resolved to frames at build
};
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// cleanly — not an error, not silence for the whole map — and reported here so the editor
// can flag/prune it.
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
// drift.
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// -> empty result.
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
// empty selection, or a stale id -> nullopt.
//
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// (both share foldZone, so the drift test keeps the shared fold honest).
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map);
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
// resolveCapture, so the drift test keeps the shared fold honest).
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
const std::string& selectionId,
const InstrumentParams& params);
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map);
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
const std::string& selectionId,
const InstrumentParams& params);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
struct DecodedZonePcm {
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
struct DecodedPcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
// `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (average all source channels).
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// Empty/zero-channel input -> empty frames (caller plays silence).
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
// plays across the whole keyboard, repitched from the effective root. A second channel is
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
// unplayable SampleData (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
// so both artifacts share the codec while only the VST links the voice engine.