Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 07:15:54 -04:00
parent a689fb75eb
commit 8d4ccbf841
61 changed files with 5416 additions and 8008 deletions
+88 -144
View File
@@ -1,10 +1,11 @@
#pragma once
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
// 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, mono downmix, keymap build) is pure and unit-tested
// here. Links bank_book, wav_codec, and sampler_core (all pure).
// 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>
@@ -12,7 +13,7 @@
#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/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map {
@@ -29,7 +30,7 @@ struct SelectedSample {
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 GA channel-mode auto-default skips it
// the channel-mode auto-default skips it
};
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
@@ -60,8 +61,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
// 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 PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
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
@@ -74,10 +73,9 @@ 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);
// 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
@@ -123,10 +121,10 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// 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.
// 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);
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// --- 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
// keymap 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).
// 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.
struct AdsrSeconds {
@@ -162,10 +160,10 @@ struct PitchEnvSeconds {
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). Instrument-owned, serialized, editor-facing —
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
struct ZonePlaySeconds {
// 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: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
@@ -173,46 +171,28 @@ struct ZonePlaySeconds {
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// 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).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
PlayParams resolvePlay(const PlaySeconds& 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` (Keymap::singleSampleChromatic).
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// picked single capture plays under the same default engine as a zone would. 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 (the instrument's OWN state) ---------------
// --- The instrument's ONE parameter set (its 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. A performance
// choice, so it lives in the instrument (VST3 component state), never written back to the
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// shell resolves+decodes each id's WAV, and 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.
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
// read position are facts about the file, but the instrument may override them per zone
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// initial read frame, absent -> 0). 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
// 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 -> 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
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
@@ -223,116 +203,80 @@ struct PerformanceZone {
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. Per-zone. 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 zone's soft hits
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// in Voice::start.
// 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();
// Per-zone 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); keymap build resolves to frames at the live sample rate. Defaults
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// to exactly these defaults on read.
ZonePlaySeconds play;
// 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 instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution — first zone in order wins (mirrors the core's first-match
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
//
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
// control edit. 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. This function is called
// at every selection-change site so the zone the editor draws is the zone the engine plays.
//
// Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not full [0,127]) -> 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) -> keep only the first zone bound to `selectedId`
// (params preserved); drop the rest. A selection with no zone yet empties the map.
// 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: project-
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// an id) — this is the id resolved against the live bank.
struct ResolvedZone {
// 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 lowNote = 0;
int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
PlaySeconds play; // stored SECONDS; resolved to frames at build
};
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// cleanly — not an error, not silence for the whole map — and reported here so the editor
// can flag/prune it.
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// 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 a performance map against the live "banks" ext-state blob. Each zone's sampleId
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// -> empty result.
// 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 resolvePerformanceFromRefs (the
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// (both share foldZone, so the drift test keeps the shared fold honest).
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map);
// 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 of resolvePerformance, against the INSTANCE-OWNED refs table —
// shares 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).
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map);
// 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);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
struct DecodedZonePcm {
// 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
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the cross-mode channel policy 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.
// 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 drops the zone or plays silence).
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// Empty/zero-channel input -> empty frames (caller plays silence).
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// 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.