Files
reasampler/src/core/instrument/map/sample_map.h
T
daniel da14509ab5 Restore the bank fold and usage publish to the resume path, guard setActive against repeats, and make the meter fold's bound literal
The resume also hands back to a full reload when the fold moves the loaded capture's decode source, so the refs table and the audio cannot skew.
2026-08-02 14:06:37 -04:00

276 lines
17 KiB
C++

#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, play_params, and play_seconds (all pure) —
// deliberately NOT the voice engine: the build's product is plain SampleData.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/instrument/map/play_seconds.h" // PlaySeconds (the stored seconds value layer)
#include "core/instrument/note/musical_division.h" // Division (the bake hold's value domain)
#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<SelectedSample> 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<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. One capture = at most one id; the list form is
// kept because the refs-table helpers below are id-set operations.
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
// 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);
// True when two refs would build the same SampleData: path plus every intrinsic
// resolveCapture folds. displayName is excluded on purpose — it is a label, never a decode
// input. Exists so a caller holding an ALREADY-DECODED sample can ask whether a refresh moved
// what that sample was decoded from; comparing the fields at the call site instead would go
// stale the first time this struct gains one.
bool sameDecodeSource(const SelectedSample& a, const SelectedSample& b);
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
// the refs table is non-empty).
// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
// shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
// grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& 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<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 ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
// past the last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Seconds -> frames --------------------------------------------------------
// 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<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// 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;
// How long the resample bake holds the gate. Read ONLY when the bake window cannot be
// derived — a Gate voice over an active sustain loop, which sounds indefinitely
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
// the field lifts to it, and no other bake changes.
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
// The master-bus limiter's single enable. It sits OUTSIDE PlaySeconds deliberately: it is
// a post-voice-mixer concern the shell applies to the summed output, never a voice
// parameter, so it must not ride into the live block or the SampleData build. Default off
// — a blob predating the field lifts to bypassed and sounds identical.
bool limiterEnabled = false;
};
// 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<ResolvedCapture> 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<ResolvedCapture> 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<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
// 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<AudioSample>& 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