Cut core/instrument/map comment bloat ~30% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:48:35 -04:00
parent 1f24c4b095
commit 354192ae27
12 changed files with 546 additions and 814 deletions
+7 -16
View File
@@ -10,20 +10,14 @@
namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) {
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
// reject to the absent default (0); the guarded accumulate means a
// pathologically long digit run can never wrap into a bogus small value.
// Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
// guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
std::int64_t value = 0;
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value;
}
std::string formatBankGeneration(std::int64_t generation) {
// Non-negative decimal; a negative (should never be produced by the writer) formats as
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
// stays in the >= 0 domain by construction.
return std::to_string(generation);
}
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
AssignConsumeDecision d;
d.consumedGeneration = lastConsumed; // default: nothing changes
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
// Rule 1: no request, or not newer than what we already consumed.
if (!request) return d;
if (request->generation <= lastConsumed) return d;
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
// advance the marker (stay eligible if focus later lands here). No thundering herd.
// Rule 2: new but not our target -> don't advance the marker, stay eligible.
if (!isFocusedTarget) return d;
// The request is new AND we are the target: it will be consumed-as-seen either way, so
// advance the marker to its generation so it is never re-evaluated.
// New and our target: consumed-as-seen either way.
d.consumedGeneration = request->generation;
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
// advanced above, but no selection change.
// Rule 3: unresolvable -> drop silently, marker already advanced above.
if (!resolves) return d;
// Rule 4: new, target, resolvable -> apply the selection.
// Rule 4: new, target, resolvable -> apply.
d.apply = true;
d.bankId = request->bankId;
d.sampleId = request->sampleId;
+27 -74
View File
@@ -1,21 +1,11 @@
#pragma once
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
// host-facing shell.
//
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
// read crosses the bridge in the shell; every DECISION after — parse the generation
// stamp, decide whether it differs from what we last saw, decide whether a decoded
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
//
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
// bank_sync — decision logic for bank-generation change-detection and the instrument-side
// assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
// audio thread: the bank-generation counter (has the bank changed?) and the assignment
// request (should I switch to a just-ingested sample?). The shell reads the raw strings and
// owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
// setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
// without a host. assignment_request.h owns the wire format; this owns the consume decision.
#include <cstdint>
#include <optional>
@@ -27,41 +17,26 @@ namespace reasampler::instrument::map {
using wire::AssignmentRequest;
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
// Default for a project with no bank_generation key yet (pre-existing project); the first
// real bump (>= 1) then reads as a change against this.
inline constexpr std::int64_t kBankGenerationAbsent = 0;
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
// poll once the value is clean).
// Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
// never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
// so a torn/partial write is ignored until the next clean poll.
std::int64_t parseBankGeneration(const std::string& raw);
// Format a bank-generation counter for the ext-state stamp. The inverse of
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
// the stamp is byte-stable across writes of the same value.
// Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
// writes of the same value.
std::string formatBankGeneration(std::int64_t generation);
// Has the bank generation changed since the reader last saw `seen`? True when `current`
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
// an increase): the writer is monotonic, but a project switch or reload can legitimately
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
// first-bump refresh the spec requires).
// True when `current` differs from `seen` (not just increases — a project switch/reload can
// legitimately lower the value, and the reader should still re-read the bank).
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
// The verdict of the S8 assignment-request consume decision (below). A pure value the
// processor shell acts on: apply the selection (or not) and advance the consumed marker
// (or not). Distinct booleans because the two are NOT the same event — a request may be
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
// every poll.
// Verdict of the assignment-request consume decision below. apply and consumedGeneration
// advancing are NOT the same event — a request naming an unresolvable sample is dropped
// (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
struct AssignConsumeDecision {
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
std::string bankId; // the request's bank (valid only when apply)
@@ -69,37 +44,15 @@ struct AssignConsumeDecision {
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
};
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
// `lastConsumed` persists across reopen so a request already applied and manually changed
// away from is not reapplied. `resolves` is whether (bankId, sampleId) exists in the live
// bank right now. `isFocusedTarget` gates thundering-herd (only the focused-editor instance
// applies; others neither apply nor advance their marker, staying eligible if focus moves).
//
// `request` — the decoded assignment request (nullopt when the assign_request key
// is absent / malformed — nothing pending).
// `lastConsumed` — the generation this instance last consumed (persisted in component
// state so a re-open does not re-apply a request the user already got,
// then manually changed away from). Defaults to 0 for a fresh instance.
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
// sample RIGHT NOW (the shell computed this against the live bank blob).
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
// thundering-herd policy (e.g. only the focused-editor instance applies).
// The shell passes true when this instance should act; false suppresses
// consumption entirely so a non-target instance neither applies nor
// advances its marker (it stays eligible if it later becomes the target).
//
// RULES (all pure, order matters):
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
// request's generation, so it is not re-applied.)
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
// apply and do NOT advance the marker — a non-target instance must stay able to consume
// the request if focus later lands on it. (No thundering herd: only the target acts.)
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
// but DO advance the marker to the request's generation so a stale/unresolvable request
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
// advance the marker to the request's generation.
//
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
// consumedGeneration into component state when it advanced.
// Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
// the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
// resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
// (4) new, target, resolves -> apply + advance marker.
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget);
+2 -3
View File
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
// both the return AND the buffer: a caller that reused a dirty buffer must not
// surface stale bytes as a value when the API reported nothing.
// 0 return means absent; guard the buffer too so a reused dirty buffer can't
// surface stale bytes as a value.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer;
}
+6 -27
View File
@@ -1,21 +1,8 @@
// bridge_marshal.hPURE marshalling helper for the REAPER VST-host bridge read.
// NO VST3, NO REAPER types at the boundary.
//
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out
// of a REAPER-facing shell.
//
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
// stand-in until the instrument could parse the bank properly. S4 retired it: the
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
// is back to its one honest job — the API-return decode.
// bridge_marshal — pure GetProjExtState result decode for the REAPER VST-host bridge read.
//
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
// -- returns the length written (0 when the key is absent).
// int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
// returns the length written (0 when the key is absent).
#pragma once
@@ -26,18 +13,10 @@
namespace reasampler::instrument::map {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
// the key) and the buffer it filled. Returns the value only when the API reported a
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
//
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
// wrote (already truncated to the C string by the caller).
// Value only when the API reported non-empty AND the buffer is non-empty — REAPER
// writes 0 and leaves the buffer untouched for an absent key, so stale buffer
// contents must never read as a hit.
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it
// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1
// extension-side, so it belongs on the neutral wire seam, not the instrument map).
} // namespace reasampler::instrument::map
+103 -116
View File
@@ -1,8 +1,6 @@
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7)
// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library +
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
// Every wire format is FROZEN — byte-identical across revisions.
#include "core/instrument/map/component_state_io.h"
@@ -28,13 +26,11 @@ namespace {
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
// Append the zones payload — the shared body of the performance blob and the component blob,
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
// the zone count so any reader can detect the record shape independently of the envelope version
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
// 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);
@@ -49,7 +45,7 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
}
// S11 extension: loop override (hasLoop flag + start/end), then start point.
// 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);
@@ -59,9 +55,9 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putLE(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
// fraction. Order matches the header's v5 record spec.
// 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
@@ -78,10 +74,10 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
// 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) {
@@ -91,30 +87,30 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
}
}
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
// keeps the zones that parsed cleanly and drops the rest.
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
// 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 S11 loop/start tail is present
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
}
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
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+ (S-VIEW-6): per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
// 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;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
@@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
if (hasStart) z.startPoint = r.i64();
}
if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
// 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;
@@ -169,21 +165,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
}
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
// 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 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
// 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 actually hold (16 bytes/point) so a corrupt huge
// count can't trigger a giant allocation before the bounded reads fail — the loop still
// stops on r.ok, this only caps the speculative reserve.
// 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) {
@@ -211,15 +206,14 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
// 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 S4 single-selection format (version 1 + id bytes,
// 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);
@@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
return map;
}
// --- Combined component state (v3, S10) --------------------------------------
// --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out;
putLE(out, kComponentStateVersion);
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
// v4 addition: channel mode (0 mono/1 stereo) precedes the v3 body.
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
// stops at the mode byte is a strict prefix (see the v4 lift below).
// v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
// the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below).
putLE(out, asU64(state.lastConsumedAssignGeneration));
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
// v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
// prefix up to this byte (see the v5 lift).
out.push_back(state.previewVelocity);
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
// v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
// (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount;
out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
// negative falls back to unity; above the +24 dB cap clamps to the cap.
// v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
// strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
// falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear();
@@ -272,16 +263,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
if (g > maxLin) g = maxLin;
putLE(out, doubleToBits(g));
}
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
// channel count); 1 = the user deliberately toggled the mode (never fought).
// v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
// here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
// 1 = user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
// written), channelCount, displayName (length-prefixed; display-only).
// 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
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
// channelCount, displayName (length-prefixed; display-only).
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) {
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
@@ -312,17 +301,17 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// v5+. See readZonesPayload for the guard.
ComponentState out;
ByteReader r(bytes);
const std::uint32_t version = r.u32();
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
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 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
// zone} so the old pick survives as BOTH the selection and a one-zone map.
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
// * 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.
if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes);
@@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
return out; // channelMode stays Mono (pre-S7)
return out; // channelMode stays Mono
}
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
// the id length + id + zones body starts right after the version tag (no mode byte).
// 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) {
const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
return out; // channelMode stays Mono, marker stays 0
}
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
// defaults to 0, so a first assign still applies for a pre-marker instance.
// 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
// a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
@@ -359,12 +348,12 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // marker stays 0 (pre-S8/S9 reader)
return out; // marker stays 0
}
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
// pre-S-VIEW-4 instance restores at the mid default.
// 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.
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
// already-saved instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
@@ -375,7 +364,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate);
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
return out; // previewVelocity stays at the mid default
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
@@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // unknown -> empty
}
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
// instance.
// 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.
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;
@@ -402,8 +390,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel
: kPreviewVelocityDefault;
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
// 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) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
@@ -417,9 +405,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
}
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
// 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) {
const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries
@@ -429,18 +417,18 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
? g
: 1.0;
}
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
// un-touched default and the shell may auto-default it from the loaded capture.
// 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) {
const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1);
}
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
// 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) {
const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
@@ -449,11 +437,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen);
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
// corrupt field must degrade to the field's default, never poison playback
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
// to the middle-C default distill() uses; a negative channel count falls back
// to 0 = unknown (the GA auto-default then skips it).
// Range fallbacks: the refs table is the ONLY copy on the play path, so a
// corrupt field must degrade to the field's default, never poison playback. An
// out-of-MIDI-range root falls back to the middle-C default distill() uses; a
// negative channel count falls back to 0 = unknown (auto-default then skips it).
const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0);
@@ -468,8 +455,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
if (!r.ok) return out;
}
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
// EMPTY default holds and the processor mints a fresh identity on first publish.
// 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) {
const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen);
+188 -224
View File
@@ -1,20 +1,15 @@
#pragma once
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps.
// 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.
//
// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
// so the payload and the instrument's reader can never drift. Housing it inside
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
// to serialize one preset blob; split out, both artifacts link the codec and only the
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
// value validation) — never the engine.
//
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
// payload v1..v7) must be preserved exactly.
#include <cstdint>
#include <string>
@@ -26,108 +21,86 @@ namespace reasampler::instrument::map {
// --- Performance-map instance state (VST3 setState/getState) -----------------
//
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
// truncation/wrong-version by design (bounded reads, never throws across the host).
// 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).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
//
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
// key composition property: the zone-record extension is versioned inside the map blob, not on
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
// collide on a single version number.
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// to each zone record after rootOverride:
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
// 8-byte LE loop.end (both two's-complement int64);
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
// 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 (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.
// * 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.
//
// These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
// These two functions serialize the ZONES only; the instrument's full component state is
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// always tiny) can never collide with.
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail:
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended to each zone record after the v6 keyTrack field:
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
// 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;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// (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.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
@@ -139,152 +112,145 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
// --- Combined component state (VST3 setState/getState, v3+) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
// demoted opt-in overlay (the performance map). The component state carries both so a saved
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
// 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.
//
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
// only v10 addition over v9 — the envelope grows a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
// 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
// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
// 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
// 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.
// 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.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty tablebridge-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).
//
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
// already got and then manually changed away from: on re-open the instance re-reads the pending
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
// 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
// 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.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
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
// count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
// choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
std::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.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
// older blob lifting to these plays byte-identically.
// 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.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
// 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.
double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
// for every bank sample this instance plays (see the SampleRefs block above). setState
// decodes straight from these; NO bridge/extension read is required for playback. A
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
// 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).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
// the key is stable across sessions. Empty = never published (a fresh or pre-v11
// instance); the processor mints one on first publish, and RE-mints when the publish
// plan detects this state was cloned onto another track (FX copy / track duplication).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
// 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + 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;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
// 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;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
// in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
// v6 blob to the voice defaults {16, Poly, Retrigger}.
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
// {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
// deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
// Selection + zones + channel mode, no consumed marker. Retained so
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
// blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
@@ -300,18 +266,16 @@ 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 (D-B: the selection is a
// performance choice, held by the instrument, never written back to the bank). It is a
// single string id. serialize/deserialize keep the on-the-wire form explicit and
// versioned so a future Tier can extend it without breaking already-saved instances.
// 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.
//
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
// length prefix is needed the id runs to the end of the stream (the host tells us the
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
// by returning "" (no selection — under the S10 policy reversal an empty selection is
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
// 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.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
+4 -6
View File
@@ -1,4 +1,4 @@
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
// note_entry.cpp — see note_entry.h.
#include "core/instrument/map/note_entry.h"
@@ -40,8 +40,8 @@ int letterSemitone(char up) {
}
}
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
@@ -49,10 +49,8 @@ std::optional<int> parseNoteName(const std::string& s) {
if (base < 0) return std::nullopt; // not a letter -> not a note name
++i;
int semitone = base;
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
// Optional accidental(s): # / b only (not 's'/'f').
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
// an accidental. '#' raises, 'b'/'B' lowers.
if (s[i] == '#') ++semitone;
else --semitone;
++i;
+6 -21
View File
@@ -1,21 +1,9 @@
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
// note_entry parse + clamp for direct numeric/note-name entry of a zone's low/high/root
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
//
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
// note [0,127] and commits via the same off-thread reload as every other edit.
//
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
// nullopt (the shell keeps the old value + may flash the field).
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
// rejecting; unparseable input returns nullopt (shell keeps the old value).
#pragma once
@@ -24,10 +12,7 @@
namespace reasampler::instrument::map {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map
+27 -46
View File
@@ -1,6 +1,5 @@
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER /
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
// sample_map — pure implementation (the resolution half; the ComponentState codec lives
// in component_state_io.cpp). See sample_map.h.
#include "core/instrument/map/sample_map.h"
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
// a SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
@@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) {
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
// un-rooted sample plays unity at C4 rather than failing to play.
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
out.channelCount = s.channelCount; // 0 = unknown (older entry)
return out;
}
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
// Shared so the two resolution paths cannot drift.
// 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;
// Effective root: override beats intrinsic (distill already defaulted an empty
// intrinsic to middle C).
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
// carried straight through and applied at play time.
// Key tracking + velocity curve are instrument state — carried straight through.
rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve;
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
// 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;
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
// resolves them to frames.
rz.play = z.play;
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
return rz;
}
@@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
// circuit before parsing — no stored id resolves to nothing to play by design.
// An empty selection is SILENCE, not the first sample — by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
// Search every bank (ordinal order) for the stored id; a sample lives in exactly
// one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
// the editor reflects the missing pick with its empty state rather than masking it.
// A stale stored id is SILENCE too — the editor's empty state, not a substitution.
return std::nullopt;
}
@@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (pS self-contained playback) -------------
// --- Instance-owned sample references (self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
@@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
e.displayName = found->displayName; // rename sync
updated = true;
break;
}
@@ -233,22 +221,18 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
// out-of-range channel request to the last channel, so a mono source yields L == R.
// extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
// 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)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
@@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) {
// Look the id up across every bank (pool + named) — a sample lives in exactly
// one bank, so first hit wins.
// 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)) {
@@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
}
}
if (!found) {
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
out.droppedSampleIds.push_back(z.sampleId);
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
continue;
}
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
// run the SHARED fold — so the bank path and the refs path resolve identically.
// 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;
@@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r));
} else {
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
// zone cleanly + report — the same shape as the bank path's stale-id policy.
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId);
}
}
+163 -246
View File
@@ -1,22 +1,10 @@
#pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable
// arithmetic out of a host-facing shell.
//
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
// Keymap — is pure and unit-tested here.
//
// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
// SampleData it produces). All three are pure; this stays pure.
// 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.
// 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).
#include <cstdint>
#include <optional>
@@ -29,77 +17,56 @@
namespace reasampler::instrument::map {
// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in
// instrument::map; the engine family stays in flat `reasampler` until its own wave).
using audio::AudioSample;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The bank sample this instance is bound to, distilled from the live "banks" blob:
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
// The bank sample this instance is bound to, distilled from the live "banks" blob: the
// project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
// core repitches/loops by. A pure value — no host, no PCM yet.
struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
// bank entries) — the GA channel-mode auto-default skips it
std::string relativePath; // project-relative; the shell resolves it
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
};
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
// project). `sampleId` is this instance's stored selection.
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
// an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
//
// Precedence, all pure:
// * empty / malformed banksJson -> nullopt (nothing to play)
// * sampleId empty -> nullopt (NO selection -> silence)
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
// the editor returns to the empty state)
//
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
// nullopt rather than silently substituting a different sample — the editor reflects the
// missing selection with its "pick a capture" empty state instead of masking it.
// Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
// is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
// an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
// in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
// selection — the editor shows its "pick a capture" empty state rather than masking it.
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
// instance's current mode, and whether the user has explicitly toggled the mode, return
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
// * isExplicit == true -> current (user's choice stands)
// * channelCount == 0 -> current (unknown, skip)
// * channelCount >= 2 -> Stereo
// * channelCount == 1 -> Mono
// Auto-default rule: given the capture's channel count, current mode, and whether the user
// explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
// channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
// --- Instance-owned sample references (pS self-contained playback) -------------
// --- Instance-owned sample references (self-contained playback) -------------
//
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
// its OWN component state, a small table of everything it needs to PLAY each referenced
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
// refreshes this table opportunistically when readable (recapture/root edits stay live),
// never a runtime lifeline.
// The instrument must never go silent just because the extension's ext-state hasn't parsed
// yet (or the extension is absent). So the instance persists, in its OWN component state, a
// table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
// (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
// these refs; the bank blob is a browser source that refreshes the table opportunistically
// when readable, never a runtime lifeline.
//
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
// 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
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
// Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
// snapshot is unavailable); never consulted by resolution.
std::string displayName;
};
using SampleRefs = std::vector<SampleRefEntry>;
@@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
// distillation selectSample performs), copying the bank display name alongside the decode
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
// 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
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
// instance references?
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
// project's ext-state may simply not have parsed).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
// refs table then goes non-empty and the lift never re-fires).
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
// the refs table is non-empty).
// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
// shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
// exactly what the instance currently plays, so it cannot grow with browsing history).
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
// grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
// One entry in the capture browser's card list: the stable id + display name plus the S2
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
// parse — the UI never parses JSON itself.
//
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
// badge shows "root: —" / no root, never a guessed value).
// - key: the optional human musical key label ("F#m"), when the bank set it.
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
// One entry in the capture browser's card list: stable id + display name + intrinsics +
// bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
// NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
// cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
// guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
struct SampleChoice {
std::string id;
std::string displayName;
@@ -167,35 +123,27 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields:
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
// their source channel count, so a stereo (or N-channel) capture is folded to a single
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
// the least-surprising, no-clip default — a centered mono source stays unity, and 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 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.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
// past the last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
//
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
// 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).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
@@ -214,61 +162,51 @@ 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). This is the instrument-owned (D-B), serialized, editor-facing
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
// 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 {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
// 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);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate.
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
// picked single capture plays under the same default engine as a zone would. This function
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
// 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 (Tier 1, D-B: the instrument's OWN state) ---------------
// --- Performance map (the instrument's 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. It is a
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
//
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
// stitches the decoded frames + this map into a sampler_core Keymap.
// 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,
// with an optional root-note override. rootOverride absent -> repitch from the bank
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
//
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
// 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
@@ -277,146 +215,125 @@ struct PerformanceZone {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// engines.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
// 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.
VelocityCurve velocityCurve = VelocityCurve::flat();
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
// 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;
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
// first-match Keymap::resolve overlaps are neither rejected nor clamped, the earlier
// zone simply takes the contested keys; documented, deterministic).
// 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 (issue 3a).
// 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 (ensureSampleZone). 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 (matched by
// sampleId, order-blind). This function is called at every selection-change site so the zone
// the editor draws is the zone the engine plays.
// 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 (pure, order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not the full [0,127]) -> the map is 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 — the map is purely Sample-face-shaped): keep only the
// first zone bound to `selectedId` (the selection's own params are not reset); drop
// the rest. A selection with no zone yet empties the map (the shell then plays the
// selection via the Tier-0 fast path with product defaults).
// 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: the bank
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
// 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 {
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; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
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)
};
// The result of resolving a performance map against the live bank blob. `zones` are the
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
// for the whole map — and its id is reported here so the editor can flag/prune it).
// `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;
};
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
// 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.
//
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
// foldZone, so the drift test is what keeps the shared fold honest.
// 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);
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
// playback) — the bank-free mirror of resolvePerformance, sharing 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). Pure.
// 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);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
// is preserved so first-match overlap resolution matches the map's authored order. A zone
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
// map). Empty zones in -> empty Keymap (silence).
// 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 {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
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 S7 cross-mode channel policy (D-E) 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.
// * MONO mode -> downmix to one channel (the existing policy: average all source
// channels). framesR EMPTY. A mono or stereo source both collapse.
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
// takes channels 0 and 1 (documented; the sampler's stereo image is
// the first two channels — no surround fold).
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
// 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.
// * 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);
// The ComponentState envelope + zones-payload binary codec (serializePerformance /
// serializeComponentState / serializeSelection + the deserializers and every version
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows
// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the
// split lets both artifacts share the codec while only the VST links the voice engine.
// The ComponentState envelope + zones-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.
} // namespace reasampler::instrument::map
+1 -1
View File
@@ -1,4 +1,4 @@
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
// trigger_seam.cpp — see trigger_seam.h.
#include "core/instrument/map/trigger_seam.h"
+12 -34
View File
@@ -1,25 +1,10 @@
// trigger_seam.hPURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
//
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
// of Trigger fade lengths:
//
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
// that anchor directly to the voice's source-timeline read pointer.
//
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
// span, where the played span is:
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
// the engine stores frames so the voice advances correctly at the live rate.
//
// This module owns the one shared formula so the pack (frames->fractions) and unpack
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
//
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
// invariant (a drag can never produce a value a slider couldn't reach).
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
#pragma once
@@ -27,25 +12,18 @@
namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span:
// postStart = max(0, frameCount - startFrame)
// playLength = round(lengthFraction * postStart)
// `frameCount` is the total decoded sample length in source frames.
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
// Returns 0 when postStart == 0 or lengthFraction <= 0.
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
// or lengthFraction <= 0.
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
// UNPACK direction (commit path): fraction -> nearest source frame.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map