Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user