Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+714
View File
@@ -0,0 +1,714 @@
#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_trim / 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_trim (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.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler {
// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map
// re-namespaces in its own split wave (Q-W2v).
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.
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
};
// 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.
//
// 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.
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
// instance's current mode, and whether the user has explicitly toggled the mode, return
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
// * isExplicit == true -> current (user's choice stands)
// * channelCount == 0 -> current (unknown, skip)
// * channelCount >= 2 -> Stereo
// * channelCount == 1 -> Mono
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
// --- Instance-owned sample references (pS 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.
//
// 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
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).
std::string displayName;
};
using SampleRefs = std::vector<SampleRefEntry>;
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
// Every bank sample id this instance plays: the selection (when set) + each zone's
// sampleId, de-duplicated, selection first then map order.
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// 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.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
// instance references?
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
// project's ext-state may simply not have parsed).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
// refs table then goes non-empty and the lift never re-fires).
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
// exactly what the instance currently plays, so it cannot grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
// One entry in the capture browser's card list: the stable id + display name plus the S2
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
// parse — the UI never parses JSON itself.
//
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
// badge shows "root: —" / no root, never a guessed value).
// - key: the optional human musical key label ("F#m"), when the bank set it.
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
struct SampleChoice {
std::string id;
std::string displayName;
std::optional<int> rootNote;
std::optional<std::string> key;
std::string bankId;
};
std::vector<SampleChoice> listSamples(const std::string& banksJson);
// One bank the filter tab strip offers: its stable id + display name, in ordinal order
// (pool first). The browser prepends an "All" tab (no id) shell-side. Empty for an empty /
// malformed blob. Pure projection over the shared parse.
struct BankChoice {
std::string id;
std::string displayName;
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_trim::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.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
//
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
double attackSeconds = 0.003; // tier-0 default
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060; // tier-0 default
};
// The stored AD pitch-envelope times (seconds). enabled + peakSemitones are dimensionless.
struct PitchEnvSeconds {
bool enabled = false;
double attackSeconds = 0.0;
double decaySeconds = 0.0;
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.
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)
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).
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.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
//
// 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.
// 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.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
int highNote = 127; // inclusive
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
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).
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).
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).
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
//
// 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.
//
// 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).
// 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.
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)
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).
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
//
// 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.
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.
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).
struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (the existing policy: average all source
// channels). framesR EMPTY. A mono or stereo source both collapse.
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
// takes channels 0 and 1 (documented; the sampler's stereo image is
// the first two channels — no surround fold).
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// --- Performance-map instance state (VST3 setState/getState) -----------------
//
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
// truncation/wrong-version by design (bounded reads, never throws across the host).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
//
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
// key composition property: the zone-record extension is versioned inside the map blob, not on
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
// collide on a single version number.
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// to each zone record after rootOverride:
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
// 8-byte LE loop.end (both two's-complement int64);
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
//
// These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// always tiny) can never collide with.
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail:
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended to each zone record after the v6 keyTrack field:
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
// demoted opt-in overlay (the performance map). The component state carries both so a saved
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
//
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
// selection-id length + id bytes, then the CURRENT zones payload (identical to
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
// only v10 addition over v9 — the envelope grows a field,
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
//
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
// already got and then manually changed away from: on re-open the instance re-reads the pending
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
// older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
// for every bank sample this instance plays (see the SampleRefs block above). setState
// decodes straight from these; NO bridge/extension read is required for playback. A
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
// v6 blob to the voice defaults {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Instance state (VST3 setState/getState) --------------------------------
//
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
// performance choice, held by the instrument, never written back to the bank). It is a
// single string id. serialize/deserialize keep the on-the-wire form explicit and
// versioned so a future Tier can extend it without breaking already-saved instances.
//
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
// length prefix is needed — the id runs to the end of the stream (the host tells us the
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
// by returning "" (no selection — under the S10 policy reversal an empty selection is
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler