330 lines
19 KiB
C++
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
|