diff --git a/src/core/instrument/map/bank_sync.cpp b/src/core/instrument/map/bank_sync.cpp index 10532e9..356bb29 100644 --- a/src/core/instrument/map/bank_sync.cpp +++ b/src/core/instrument/map/bank_sync.cpp @@ -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& 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; diff --git a/src/core/instrument/map/bank_sync.h b/src/core/instrument/map/bank_sync.h index 856bfe5..a85ae8e 100644 --- a/src/core/instrument/map/bank_sync.h +++ b/src/core/instrument/map/bank_sync.h @@ -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 #include @@ -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& request, std::int64_t lastConsumed, bool resolves, bool isFocusedTarget); diff --git a/src/core/instrument/map/bridge_marshal.cpp b/src/core/instrument/map/bridge_marshal.cpp index 2163b55..63cf48b 100644 --- a/src/core/instrument/map/bridge_marshal.cpp +++ b/src/core/instrument/map/bridge_marshal.cpp @@ -6,9 +6,8 @@ namespace reasampler::instrument::map { std::optional 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; } diff --git a/src/core/instrument/map/bridge_marshal.h b/src/core/instrument/map/bridge_marshal.h index 53a003c..c39bfb9 100644 --- a/src/core/instrument/map/bridge_marshal.h +++ b/src/core/instrument/map/bridge_marshal.h @@ -1,21 +1,8 @@ -// bridge_marshal.h — PURE 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 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 diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index bcec2b6..0fb93de 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -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(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& out, const PerformanceMap& map) { putLE(out, kZonesFormatMarker); putLE(out, kZonesPayloadVersion); @@ -49,7 +45,7 @@ void putZonesPayload(std::vector& out, const PerformanceMap& map) putLE(out, static_cast(static_cast(*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& 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& 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& pts = z.velocityCurve.points(); putLE(out, static_cast(pts.size())); for (const VelocityPoint& p : pts) { @@ -91,30 +87,30 @@ void putZonesPayload(std::vector& 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 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(ptCount), remaining / 16)); for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { @@ -211,15 +206,14 @@ std::vector serializePerformance(const PerformanceMap& map) { PerformanceMap deserializePerformance(const std::vector& bytes, double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 - // blob is encountered with an invalid rate — the calller guarantees a real rate before use. + // 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& bytes, return map; } -// --- Combined component state (v3, S10) -------------------------------------- +// --- Combined component state -------------------------------------- std::vector serializeComponentState(const ComponentState& state) { std::vector 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(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 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(state.sampleRefs.size())); for (const SampleRefEntry& e : state.sampleRefs) { putLE(out, static_cast(e.sampleId.size())); @@ -312,17 +301,17 @@ std::vector serializeComponentState(const ComponentState& state) { ComponentState deserializeComponentState(const std::vector& bytes, double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. See readZonesPayload for the guard. + // 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& 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& 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& 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& 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& 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& 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& 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& 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& 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); diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index a66b1f4..104a611 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 #include @@ -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 v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone -// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the -// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. -// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp -// transfer curve appended to each zone record after the v6 keyTrack field: -// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp -// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. -// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 -// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: -// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A -// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed. -inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve +// 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 serializePerformance(const PerformanceMap& map); @@ -139,152 +112,145 @@ std::vector serializePerformance(const PerformanceMap& map); PerformanceMap deserializePerformance(const std::vector& 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_" 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_" 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 table — bridge-resolve lift). +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode. +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain. +// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults. +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte. +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker. +// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode. +// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection. +// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift. +// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state). // -// 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_" 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_" + // 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& 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; diff --git a/src/core/instrument/map/note_entry.cpp b/src/core/instrument/map/note_entry.cpp index 3a3e2a1..f2119a7 100644 --- a/src/core/instrument/map/note_entry.cpp +++ b/src/core/instrument/map/note_entry.cpp @@ -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 parseNoteName(const std::string& s) { if (s.empty()) return std::nullopt; std::size_t i = 0; @@ -49,10 +49,8 @@ std::optional 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; diff --git a/src/core/instrument/map/note_entry.h b/src/core/instrument/map/note_entry.h index b1e909d..3ecb4d4 100644 --- a/src/core/instrument/map/note_entry.h +++ b/src/core/instrument/map/note_entry.h @@ -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 parseNoteEntry(const std::string& text); } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 4f92b13..6206b1b 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -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 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 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& 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(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); } } diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 3f6d5a8..63edeab 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -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 #include @@ -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 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; @@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI std::vector 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& 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& banksJson, const std::vector& 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& 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 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 downmixToMono(const std::vector& 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 extractChannel(const std::vector& 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 frames, int sampleRate, int rootNote, const SampleLoop& loop, std::vector 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 loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional 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 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 zones; std::vector 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 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 framesR; // channel 1 (R); EMPTY for a mono decode }; Keymap buildZonedKeymap(const std::vector& zones, const std::vector& 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& 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 diff --git a/src/core/instrument/map/trigger_seam.cpp b/src/core/instrument/map/trigger_seam.cpp index 812fb3b..096bb76 100644 --- a/src/core/instrument/map/trigger_seam.cpp +++ b/src/core/instrument/map/trigger_seam.cpp @@ -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" diff --git a/src/core/instrument/map/trigger_seam.h b/src/core/instrument/map/trigger_seam.h index 6539589..2d24975 100644 --- a/src/core/instrument/map/trigger_seam.h +++ b/src/core/instrument/map/trigger_seam.h @@ -1,25 +1,10 @@ -// trigger_seam.h — PURE 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