Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user