Files
reasampler/src/core/instrument/map/sample_map.h
T

330 lines
19 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, and play_params (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/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);
// 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);
// --- 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<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;
};
// 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