#pragma once // sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain // data the sampler core plays, and resolves the instance's one capture + one parameter set. // The bank is read over the live-state seam, audio over the file seam; both raw inputs // cross the bridge/file boundary in the shell, everything after (bank parse via the shared // bank_book JSON path, sample pick, channel policy, SampleData build) is pure and // unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately // NOT the voice engine: the build's product is plain SampleData. #include #include #include #include #include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse) #include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) namespace reasampler::instrument::map { 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 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 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 channel-mode auto-default skips it }; // `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: 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); // 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 (self-contained playback) ------------- // // 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. // // 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 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 // 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; // 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. One capture = at most one id; the list form is // kept because the refs-table helpers below are id-set operations. std::vector referencedSampleIds(const std::string& selectionId); // Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display // name alongside the decode intrinsics. A miss leaves any existing entry untouched — the // 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); // 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 cannot // grow with browsing history). void retainRefs(SampleRefs& refs, const std::vector& ids); // 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; std::optional rootNote; std::optional key; std::string bankId; }; std::vector 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 listBanks(const std::string& banksJson); // Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING // channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not // summing: a centered mono source stays unity, a hard-panned source is attenuated rather // than silenced or doubled. Empty/zero-stride in -> empty out. Pure. std::vector downmixToMono(const std::vector& interleaved, int channelCount); // 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) play params ---------------------------------- // // Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The // instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as // SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at // 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; the // three curve exponents are dimensionless too (curve_law.h owns their domain). 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 double attackCurve = util::kCurveNeutral; double decayCurve = util::kCurveNeutral; double releaseCurve = util::kCurveNeutral; }; // The stored sustain-less AHD: wall-clock stage times in SECONDS, Hold as a FRACTION of the // span left after them (AhdParams owns why a fraction, not a time). struct AhdSeconds { double attackSeconds = 0.0; double decaySeconds = 0.0; double holdFraction = 1.0; double attackCurve = util::kCurveNeutral; double decayCurve = util::kCurveNeutral; }; // The stored AHD pitch envelope. enabled + peakSemitones are dimensionless. The hold fraction // defaults to 0 so an instance predating the stage plays as its attack-decay predecessor did. struct PitchEnvSeconds { bool enabled = false; double peakSemitones = 0.0; // signed depth at the peak AhdSeconds shape{0.0, 0.0, /*holdFraction=*/0.0, util::kCurveNeutral, util::kCurveNeutral}; }; // The stored mirror of the engine's FilterParams (play_params.h, which owns what each field // MEANS). Only the envelope differs between the two: the control positions and depths are // rate-free already, so this block is a seconds/frames split of one field, not of the whole // struct. The env default is a flat unity, so `enabled` is the only thing standing between a // loaded blob and the pre-filter sound. struct FilterSeconds { bool enabled = false; engine::filter::FilterSettings settings; double modAmount = 0.0; double velAmount = 0.0; double keyTrack = 0.0; AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0}; // Gate AhdSeconds trigEnv; // Trigger VelocityCurve velocityCurve = VelocityCurve::zero(); }; // The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in // frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct // from the engine-facing PlayParams (frames). struct PlaySeconds { PlayMode playMode = PlayMode::Gate; AdsrSeconds adsr; // Gate amp: AHDSR (seconds) TriggerParams trigger; // Trigger play span (%-length) AhdSeconds trigAhd; // Trigger amp: AHD (seconds + fraction) PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve PitchEnvSeconds pitchEnv; // AHD pitch modulation, off by default VelocityCurve pitchVelocityCurve = VelocityCurve::zero(); // velocity -> pitch, off by default FilterSeconds filter; // per-voice filter, off by default // The three drawn contours, in the same slots the engine bundle carries them (play_params.h // owns why they sit beside the envelopes rather than inside them). Normalized over the // sample's own length, so resolvePlay needs no rate for them. SplineEnv ampSpline; SplineEnv pitchSpline; SplineEnv filterSpline; }; // Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live // sample rate (frames = round(seconds * rate)). Source-timeline fields carry through // unchanged. `sampleRate` must be > 0 (the caller guards this). PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate); // --- The instrument's ONE parameter set (its OWN state) ----------------------- // // One loaded capture, one set of playback parameters governing it across the whole // keyboard. A performance choice, so it lives in the instrument (VST3 component state), // never written back to the bank. Pure value type: names no sample (the ComponentState's // selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and // the pure build stitches the decoded frames + this set into one SampleData. // // rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when // the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial // read position are facts about the file, but the instrument may override them without // writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial // read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective // ResolvedCapture. struct InstrumentParams { std::optional rootOverride; // instrument-owned override; absent -> bank intrinsic std::optional loopOverride; // instrument-owned sustain loop; absent -> intrinsic std::optional startPoint; // instrument-owned initial read frame; absent -> 0 // Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity // like the loop points, so it needs no rate to resolve and cannot be rescaled by a // project/file rate mismatch. 0 is the hard seam a blob predating the field lifts to. // Never a bank fact: the fade is a performance choice, the loop points are the file's. std::int64_t loopCrossfadeFrames = 0; // 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; // Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain, // replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved): // every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob // predating this field lifts to flat y=1, so an already-saved instance's soft hits play // LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in // Voice::start. VelocityCurve velocityCurve = VelocityCurve::flat(); // Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch // envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS // (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate, // tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch // engine, pitch env off. An older blob lacking this tail lifts to exactly these. PlaySeconds play; }; // The loaded capture resolved for decode + build: project-relative WAV path (file seam) // plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams // (which holds optional overrides) — this is the parameter set folded against the capture. struct ResolvedCapture { std::string relativePath; // project-relative; the shell resolves + decodes it int rootNote = 60; // effective: override, else bank intrinsic, else 60 double keyTrack = 1.0; VelocityCurve velocityCurve = VelocityCurve::flat(); SampleLoop loop; // effective: loopOverride, else bank intrinsic std::int64_t loopCrossfadeFrames = 0; // instrument-owned; no bank intrinsic to beat std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 PlaySeconds play; // stored SECONDS; resolved to frames at build }; // The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot // drift. ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params); // Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an // empty selection, or a stale id -> nullopt. // // NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned // refs). Retained as the TESTED REFERENCE the refs path is verified against (both share // resolveCapture, so the drift test keeps the shared fold honest). std::optional resolveFromBank(const std::string& banksJson, const std::string& selectionId, const InstrumentParams& params); // The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the // two paths cannot drift. A selection with no ref -> nullopt (the defined no-play). std::optional resolveFromRefs(const SampleRefs& refs, const std::string& selectionId, const InstrumentParams& params); // Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build. struct DecodedPcm { std::vector monoFrames; // channel 0 (mono, or L of a stereo decode) int sampleRate = 0; // 0 is explicitly invalid std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; // Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or // 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride = // `sourceChannels`); `mode` is the instance's channel mode. // * MONO mode -> downmix to one channel (average all source channels). // * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered). // * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels). // Empty/zero-channel input -> empty frames (caller plays silence). DecodedPcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate); // Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine // plays across the whole keyboard, repitched from the effective root. A second channel is // carried only when it length-matches channel 0 (SampleData::channelCount() enforces the // same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to // frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an // unplayable SampleData (silence, never a crash). SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded); // The ComponentState envelope + params-payload binary codec lives in component_state_io.h: // it grows on every envelope bump and is consumed by the extension's preset-blob path too, // so both artifacts share the codec while only the VST links the voice engine. } // namespace reasampler::instrument::map