Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -5,16 +5,17 @@
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, per-zone play params, pitch shifting,
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the
|
||||
small pure helpers the engine/shell share (bank-generation sync, bridge-read
|
||||
marshalling, note-name parsing, Trigger frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
|
||||
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
|
||||
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
|
||||
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
|
||||
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
||||
the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
|
||||
`reasampler_embed`, and `vst_entry`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -39,6 +40,21 @@ subdirectories:
|
||||
audio — the bank index, the mapping, which project is active — the instrument reads
|
||||
the live `"reasampler"` ext-state via the bridge.
|
||||
|
||||
### One capture = one parameter set
|
||||
|
||||
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
|
||||
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
|
||||
captures: every playback parameter edits in exactly one place, and no gesture can express
|
||||
per-zone divergence. The root note survives as a first-class parameter of that one set.
|
||||
|
||||
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
|
||||
its root, with key-tracking applied. A user-settable low/high playable range is
|
||||
re-addable later as two ordinary parameters if it is ever missed.
|
||||
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
|
||||
one's capture and zone one's parameters; the rest drop, touching no file and no bank
|
||||
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
|
||||
relaxed for a genuinely multi-zone instance.
|
||||
|
||||
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
|
||||
|
||||
The split model is the settled answer, mirroring the capture/placement separation:
|
||||
@@ -47,20 +63,18 @@ The split model is the settled answer, mirroring the capture/placement separatio
|
||||
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
|
||||
notes) are facts about the file, added as an additive field extension (same shape as
|
||||
`provenance`).
|
||||
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
|
||||
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
|
||||
trim are a performance choice, not a fact about a file — they belong to the instrument,
|
||||
not the bank. This "who owns which field" rule (D-B) governs every performance-map
|
||||
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
|
||||
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
|
||||
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
|
||||
the bank.
|
||||
- **Performance choices live in the instrument.** Amplitude envelopes and per-sample
|
||||
tuning/gain trim are a performance choice, not a fact about a file — they belong to the
|
||||
instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
|
||||
added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
|
||||
envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
|
||||
per-instance `ComponentState`, never written to `Sample` or the bank.
|
||||
|
||||
### The pure core (D3 — the load-bearing split)
|
||||
|
||||
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
|
||||
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
|
||||
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
|
||||
a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
|
||||
`bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
|
||||
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
|
||||
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
|
||||
@@ -112,7 +126,7 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
held/out of scope (fork S15-F1).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (per-zone toggle, S16).** Varispeed (current/
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
|
||||
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
|
||||
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
|
||||
@@ -157,25 +171,25 @@ The amp envelope is drawn as a curve over the Sample view's hero waveform at the
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same zone envelope fields, so a drag updates the params,
|
||||
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth,
|
||||
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a
|
||||
node cannot be dragged past its neighbours) and range-clamped to the same per-param
|
||||
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't.
|
||||
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay`
|
||||
and `envelope_edit` in Modules below.
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
|
||||
### New performance-map parameters — ownership and persistence (D-B)
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
|
||||
- **Key-tracking** — additive/version-bumped component state, default 100%
|
||||
(absent field on an older blob lifts to 100%, bit-identical playback).
|
||||
- **Preview velocity** — a per-instance utility setting for the Sample view's
|
||||
preview-trigger button (not a musical parameter of the capture); **persists across
|
||||
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
||||
extension's `persist` ext-state module (that would make it project-global rather than
|
||||
per-instance and leak an instrument concern into the extension's key space).
|
||||
- **Velocity curve** — per-zone; the one non-back-compat surface in S-VIEW: an
|
||||
already-saved zone with no stored curve now plays every velocity at unity under the
|
||||
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
|
||||
already-saved instance with no stored curve now plays every velocity at unity under the
|
||||
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
|
||||
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
|
||||
|
||||
@@ -183,16 +197,19 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
### `engine/`
|
||||
|
||||
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — zone payload: zones keyed by note range. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). JSON round-trip.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine (`sampler_core`/`pitch_shift`) to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift.
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v8), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id).
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
|
||||
@@ -200,8 +217,10 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
### `ui/`
|
||||
|
||||
- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry.
|
||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias + `contains()`, nothing else. Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split. A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver.
|
||||
- `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||
@@ -216,7 +235,9 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
- **Two superseded designs are called out in Invariants above**: the earlier
|
||||
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
|
||||
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
double tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at
|
||||
// 1.0 beyond what Attack already emitted) so a zero-length hold emits no
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||
// the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on
|
||||
// readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
+33
-20
@@ -1,18 +1,20 @@
|
||||
#pragma once
|
||||
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
|
||||
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
|
||||
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
|
||||
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
|
||||
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
||||
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
||||
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
||||
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
||||
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
@@ -25,8 +27,7 @@ enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
|
||||
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
|
||||
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
@@ -45,8 +46,7 @@ struct AdsrParams {
|
||||
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Per-zone; default Gate so an instrument with no params set plays
|
||||
// exactly as before.
|
||||
// honor the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
@@ -69,10 +69,10 @@ inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
// (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
|
||||
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
|
||||
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
|
||||
// tests (an octave up still halves duration with no params set).
|
||||
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
||||
// the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
|
||||
// so "no params == the bare engine" holds for the core's own regression tests (an octave up
|
||||
// still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
@@ -93,7 +93,7 @@ struct PitchEnvParams {
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
struct PlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
@@ -101,9 +101,6 @@ struct ZonePlayParams {
|
||||
PitchEnvParams pitchEnv;
|
||||
};
|
||||
|
||||
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||
struct SampleLoop {
|
||||
@@ -112,11 +109,14 @@ struct SampleLoop {
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
//
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
|
||||
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
|
||||
// channels share the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
||||
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
@@ -130,13 +130,26 @@ struct SampleData {
|
||||
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
ZonePlayParams play;
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
PlayParams play;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
||||
// voice on an empty read span.
|
||||
bool playable() const { return !frames.empty(); }
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,956 +0,0 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
|
||||
//
|
||||
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
|
||||
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
|
||||
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
|
||||
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
|
||||
// this file further; the header is split instead (zone_params.h carries the value structs).
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
|
||||
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
|
||||
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
|
||||
// negative fade is treated as 0. When both fades together exceed the play length, shrink
|
||||
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
|
||||
// then the fade-in — never letting either go negative or the sum exceed the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
|
||||
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
|
||||
// The offset is fractional (the read head is fractional under repitch), so the ramps are
|
||||
// smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
bool Voice::sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
|
||||
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
|
||||
// fade-in/out over the % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
|
||||
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
|
||||
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under either
|
||||
// engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
|
||||
// finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant. (An
|
||||
// earlier revision stored the frozen difference (ref − x₀), which could exceed full scale
|
||||
// if outₙ rose while that residue was still large.)
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
|
||||
// once and applied identically to every channel — only the PCM value read differs. The
|
||||
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
|
||||
// non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
|
||||
// "no loop". Under Preserve the loop is over the source read (loop the source, shift the
|
||||
// output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new play
|
||||
// span ends within the ramp). With no declick (the common case) this is byte-identical
|
||||
// to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
||||
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
|
||||
// entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose the
|
||||
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
|
||||
// amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
|
||||
// were primed with that window at start()), under the same sustain-loop wrap rule,
|
||||
// reading integer source frames (nothing to interpolate). Past the last real frame
|
||||
// the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum combing.
|
||||
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
|
||||
// that would advance its heads twice and corrupt the state). Gated on haveR so
|
||||
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
|
||||
// stereo samples, and a stale un-primed ring must not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
|
||||
// this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
|
||||
// the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
|
||||
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [Rev 1 added the frozen difference (ref − x₀) ungated; if outₙ rose while the residue
|
||||
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
|
||||
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
|
||||
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
|
||||
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
|
||||
// sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
|
||||
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
|
||||
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||
// maxVoices — the Poly path sizes the whole pool as before.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||
v.playingSample() == &sample) {
|
||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
|
||||
// The declick opt-in rides every mono restart: start() self-gates it on the voice being
|
||||
// ACTIVE, so a first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||
return;
|
||||
}
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
|
||||
// original velocity. Peer restart site of monoNoteOn's takeover — same declick opt-in
|
||||
// (the fallback also hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
|
||||
// voice being active, so a free-voice start never ramps — only an at-cap steal, which is
|
||||
// the same hard cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,485 +0,0 @@
|
||||
#pragma once
|
||||
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
|
||||
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
|
||||
// repitch/interpolation from a root note with loop-point-aware sustain.
|
||||
//
|
||||
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
|
||||
// enter as plain int/frame-index inputs; the core does no file I/O.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/zone_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
|
||||
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
|
||||
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
|
||||
// zones, each a key range with its own root.
|
||||
//
|
||||
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
|
||||
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
|
||||
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
|
||||
// receives but currently ignores for selection — no signature change needed.
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
|
||||
// note to repitch from (defaults to the sample's own root, overridable per zone).
|
||||
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
|
||||
// velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
|
||||
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
|
||||
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// `matched == false` means the note falls in no zone — a defined no-play result, not an
|
||||
// error and not voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
|
||||
// in order, so an earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
|
||||
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
|
||||
// keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
|
||||
// reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
|
||||
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
|
||||
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
|
||||
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
||||
// one value per output frame.
|
||||
double tick();
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
|
||||
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
|
||||
// frame — a step discontinuity that clicks. When the caller opts in (start()'s
|
||||
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
|
||||
// the first frame after the restart seeds a compensation equal to
|
||||
// (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
|
||||
// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
|
||||
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
|
||||
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
|
||||
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
|
||||
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
|
||||
// inside process(); the warm silence pass settles the OLA taps before the first output
|
||||
// frame. Byte-identical to the bare engine when sample.play is default.
|
||||
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
|
||||
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
|
||||
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
|
||||
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
|
||||
// difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
|
||||
// guarantees the voice is playing the same SampleData the resolved zone names — a
|
||||
// cross-sample takeover must restart the voice instead.
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// Identity only, never mutated through; the engine's mono legato path compares it
|
||||
// against the new note's resolved sample to decide retune vs. restart.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame();
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const;
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and
|
||||
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
|
||||
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
|
||||
// (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed
|
||||
// full scale by several dB.)
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
|
||||
// voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
|
||||
// engine — held by reference, never copies PCM). Play params ride on each zone's
|
||||
// SampleData::play; the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
|
||||
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
|
||||
// leaves them pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
|
||||
// same-sample takeover without a re-attack). The engine's config is immutable — a
|
||||
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
|
||||
// reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
|
||||
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
|
||||
// self-gates on the voice being active, so a fresh start never ramps. Default false
|
||||
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, zone-resolving note in press order (top = most recent = the sounding
|
||||
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
|
||||
// the voice back on a fallback). Re-pressing a held note moves it to the top.
|
||||
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
|
||||
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
|
||||
// velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
|
||||
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
|
||||
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
|
||||
// not applied in mono — a single voice runs at most one shifter, inherently within any
|
||||
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,174 @@
|
||||
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const PlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,441 @@
|
||||
#pragma once
|
||||
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
|
||||
//
|
||||
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
|
||||
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
|
||||
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/envelopes.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||
// reference-frequency needed.
|
||||
inline double pitchRatio(int note, int rootNote) {
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
|
||||
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
|
||||
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
|
||||
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
|
||||
// instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
|
||||
// AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
|
||||
// seconds at load). Preserve shifters must already be pre-sized
|
||||
// (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
|
||||
// no allocation) since it runs on the audio thread inside process(). Byte-identical to
|
||||
// the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
|
||||
// amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
|
||||
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
|
||||
void retune(int note);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop() { active_ = false; }
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
private:
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under
|
||||
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
|
||||
// advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick() {
|
||||
// The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
|
||||
// exact boundary identity whatever the new envelope's first value. Each subsequent
|
||||
// frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
|
||||
// max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
|
||||
// rises while the weight is still significant. (An earlier revision stored the frozen
|
||||
// difference (ref − x₀), which could exceed full scale if outₙ rose while that
|
||||
// residue was still large.)
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
//
|
||||
// INLINE BY CONSTRAINT — see the file header.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR) {
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
|
||||
// valid, non-zero-length loop wraps the read head back into [start, end); a
|
||||
// zero-length loop is "no loop". Under Preserve the loop is over the source read
|
||||
// (loop the source, shift the output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new
|
||||
// play span ends within the ramp). With no declick (the common case) this is
|
||||
// byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||
// floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
|
||||
// pow entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor =
|
||||
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
|
||||
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
|
||||
// rings were primed with that window at start()), under the same sustain-loop wrap
|
||||
// rule, reading integer source frames (nothing to interpolate). Past the last real
|
||||
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||
// splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||
// (never twice — that would advance its heads twice and corrupt the state).
|
||||
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||
// shiftL_.process again this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||
// the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head.
|
||||
// For the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
|
||||
// while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
|
||||
// extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
|
||||
// one extra subtract.
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any
|
||||
// running declick) — a future takeover restart seeds its declick from exactly this. In
|
||||
// a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
|
||||
// of a mono sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still
|
||||
// rings: the envelope contributes 0 from here on, so the remaining frames are the bare
|
||||
// ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,272 @@
|
||||
// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the
|
||||
// block render loops. See voice_engine.h for the contract.
|
||||
//
|
||||
// The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in
|
||||
// voice.h precisely so this TU boundary costs nothing on the per-sample path.
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
sample_(sample),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
// Nothing decoded: a defined no-play, and the note must not join the stack (it cannot
|
||||
// sound, so it must not later take the voice back on a fallback).
|
||||
if (!sample_.playable()) return kNoVoice;
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is
|
||||
// a no-op there, so releasing_ never latches and a one-shot still ringing after the last
|
||||
// key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note
|
||||
// same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so
|
||||
// heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.)
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(note);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger ignores this and plays through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
if (monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(fb.note); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||
// hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play.
|
||||
|
||||
// Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are
|
||||
// unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets
|
||||
// past the cap are refused.
|
||||
if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too: start() self-gates on the voice being
|
||||
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||
// cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap.
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
// voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and
|
||||
// stealing, the mono held-note stack, the two-tier panic, and the block render loops. The
|
||||
// per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its
|
||||
// present inline shape across this seam.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, note-on
|
||||
// allocation with bounded voice stealing, note-off routing, and block rendering (sum of
|
||||
// voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine —
|
||||
// held by reference, never copies PCM). Every playback parameter rides on the sample; the
|
||||
// engine holds no parameters of its own beyond the voice-system config below.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are pre-sized
|
||||
// to at construction (off the audio thread), so note-on never allocates; 0 leaves them
|
||||
// pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes without a
|
||||
// re-attack). The engine's config is immutable — a mode/count change rebuilds the engine
|
||||
// off-thread through the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, poly at-cap steal) seeds the per-voice declick ramp (see
|
||||
// kDeclickDecay) so the hard cut doesn't click. start() self-gates on the voice being
|
||||
// active, so a fresh start never ramps. Default false keeps the bare core byte-identical
|
||||
// to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
|
||||
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
|
||||
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, playable note in press order (top = most recent = the sounding note).
|
||||
// Re-pressing a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) —
|
||||
// no allocation on the audio thread. Velocity is kept per held note so a retrigger
|
||||
// fallback re-strikes at its original velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune, else a fresh start). Returns
|
||||
// 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (rejected before the
|
||||
// stack, which stores uint8). The Preserve cap is not applied in mono — a single voice
|
||||
// runs at most one shifter, inherently within any cap; applying it would wrongly drop a
|
||||
// Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const SampleData& sample_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,5 +1,5 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
|
||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
@@ -11,7 +11,7 @@
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
@@ -26,98 +26,134 @@ namespace {
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component
|
||||
// blob, so both write zones identically. Always emits the CURRENT payload version (marker +
|
||||
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
|
||||
// marker precedes the zone count so any reader can detect record shape independent of the
|
||||
// envelope version (see sample_map.h).
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(z.loopOverride->start));
|
||||
putLE(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in the header).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
|
||||
// trigger %-length + fades stay source frames/fraction. Order matches the header's
|
||||
// v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(out, doubleToBits(p.amp));
|
||||
}
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
|
||||
// per point velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
|
||||
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
|
||||
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
|
||||
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
|
||||
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
|
||||
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint
|
||||
// invariant defensively; a truncated read leaves the flat default.
|
||||
void readCurveTail(ByteReader& r, InstrumentParams& p) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
|
||||
if (r.ok) {
|
||||
p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
|
||||
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
|
||||
PerformanceZone z;
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
@@ -125,113 +161,88 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
|
||||
// A/D) were written as frames -> divide by `projectRate` to reach seconds.
|
||||
// Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
|
||||
// in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
readSecondsPlayTail(r, p);
|
||||
}
|
||||
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
|
||||
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
|
||||
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
|
||||
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
|
||||
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
|
||||
// read leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
|
||||
// zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
out.params = std::move(read.params);
|
||||
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Combined component state --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
@@ -268,7 +279,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
// 1 = user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
|
||||
// to here. Wire shape per kSelectionRefsV10Version: entry count, then per entry id
|
||||
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||
// channelCount, displayName (length-prefixed; display-only).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
@@ -286,75 +297,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// v11 envelope addition (usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// Length-prefixed selection id (it precedes the params payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
putParamsPayload(out, state.params);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. See readZonesPayload for the guard.
|
||||
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one
|
||||
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
|
||||
// loaded capture with default parameters.
|
||||
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono
|
||||
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
|
||||
return out; // channelMode stays Mono
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
|
||||
// length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
|
||||
// length + id + payload starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // channelMode stays Mono, marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
|
||||
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
|
||||
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
if (version == kSelectionModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
|
||||
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||
// already-saved instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
if (version == kSelectionModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -363,21 +364,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // previewVelocity stays at the mid default
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||
version != kSelectionRefsV10Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionModeMarkerVelV6Version) {
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
|
||||
// than rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -392,7 +393,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
const std::uint8_t mt = r.u8();
|
||||
@@ -408,7 +409,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||
// than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
// mode/marker/velocity/voice fields from above; unity holds
|
||||
@@ -420,7 +421,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
@@ -428,8 +429,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||
// drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
// drops the rest (the selection/params behind it are unreadable anyway).
|
||||
if (version >= kSelectionRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
SampleRefEntry e;
|
||||
@@ -457,7 +458,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||
// holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
if (version >= kSelectionRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||
@@ -465,7 +466,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,91 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
|
||||
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||
// payload and the instrument's reader can never drift — without the extension having to
|
||||
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
|
||||
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
|
||||
// engine.
|
||||
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
|
||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
|
||||
// payload v1..v7) must be preserved exactly.
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// payload v1..v8) must be preserved exactly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
// --- The instance's parameter payload ----------------------------------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
|
||||
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
|
||||
// of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
// The one parameter set is the instrument's OWN state, serialized to the VST3
|
||||
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
|
||||
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
|
||||
// OWN version, so its record can grow without bumping the envelope version. Payload
|
||||
// extensions and envelope-field additions stay on independent axes that can never collide
|
||||
// on one version number.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
|
||||
// carries its OWN version, so the per-zone record can grow without bumping the envelope
|
||||
// version. Zone-record extensions and envelope-field additions stay on independent axes
|
||||
// that can never collide on one version number.
|
||||
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
|
||||
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
|
||||
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
|
||||
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
|
||||
// case. Their record shapes, in order:
|
||||
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
|
||||
// is v1.
|
||||
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
|
||||
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
|
||||
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
|
||||
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
|
||||
// (int64). The marker lets the reader detect record shape independent of the envelope.
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
|
||||
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
|
||||
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
|
||||
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
|
||||
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
|
||||
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
|
||||
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
|
||||
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
|
||||
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
|
||||
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
|
||||
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
|
||||
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
|
||||
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
|
||||
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
|
||||
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
|
||||
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
|
||||
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
|
||||
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
|
||||
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
|
||||
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
|
||||
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
|
||||
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
|
||||
// sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
|
||||
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
|
||||
// truncated/unknown/empty blob deserializes to an EMPTY map.
|
||||
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
|
||||
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
|
||||
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
|
||||
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
|
||||
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
|
||||
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
|
||||
// threaded into the v3 lift path at read time (a parameter, no baked constant).
|
||||
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
|
||||
// tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
|
||||
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
|
||||
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
|
||||
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
|
||||
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
|
||||
// frames tail) was never shipped and is intentionally not read.
|
||||
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
|
||||
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
|
||||
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
|
||||
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||
// (soft hits play louder than under the old linear velocity/127 map).
|
||||
//
|
||||
// These two functions serialize the ZONES only; the instrument's full component state is
|
||||
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
|
||||
// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
|
||||
// SINGLE record with no count, no key range and no sample id (the envelope's selection id is
|
||||
// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte
|
||||
// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set);
|
||||
// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim
|
||||
// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7.
|
||||
//
|
||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker. serializePerformance and
|
||||
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
|
||||
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
|
||||
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
|
||||
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
|
||||
// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
|
||||
// never shipped and is not read. The marker is a high sentinel no legitimate zone count
|
||||
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
|
||||
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
|
||||
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
|
||||
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
|
||||
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
|
||||
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
|
||||
// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
|
||||
// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
|
||||
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
|
||||
// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
|
||||
// leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
|
||||
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
|
||||
// so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||
// seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3+) -------------
|
||||
//
|
||||
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and an instance with NO pick and NO
|
||||
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
|
||||
// sample #1.
|
||||
// --- Combined component state (VST3 setState/getState) -----------------------
|
||||
//
|
||||
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||
@@ -129,51 +95,53 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
|
||||
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
|
||||
// path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
|
||||
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
|
||||
// minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
|
||||
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||
// version). The instance guid is the only v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
|
||||
// PARALLEL track owns zone-record extension under its own versioning — the two version
|
||||
// numbers are independent axes; do NOT bump the zones-payload version for an envelope
|
||||
// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
|
||||
// corrupt blob) falls back to the field's default rather than silencing the instance.
|
||||
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
|
||||
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
|
||||
//
|
||||
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
|
||||
// blob) falls back to the field's default rather than silencing the instance.
|
||||
//
|
||||
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||
// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
|
||||
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
|
||||
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
|
||||
// re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
|
||||
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
|
||||
// publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
|
||||
// * v7 blob -> unity master gain.
|
||||
// * v6 blob -> voice defaults.
|
||||
// * v5 blob -> no velocity byte.
|
||||
// * v4 blob -> no marker.
|
||||
// * v3 blob -> no channel mode.
|
||||
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
|
||||
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
|
||||
// instance re-applying a stale assign_request the user already got and then manually
|
||||
// changed away from: on re-open the instance re-reads the pending request, and only a
|
||||
// generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
|
||||
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
|
||||
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
|
||||
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
|
||||
//
|
||||
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation stops a
|
||||
// re-opened instance re-applying a stale assign_request the user already got and then
|
||||
// manually changed away from: on re-open the instance re-reads the pending request, and only
|
||||
// a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written
|
||||
// to the bank — the extension owns the assign_request key; the instrument only tracks what
|
||||
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
|
||||
// with no velocity byte lifts to this, audible-but-not-hot.
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
|
||||
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
|
||||
// no velocity byte lifts to this, audible-but-not-hot.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
std::string selectionId; // the loaded capture; "" = no pick
|
||||
InstrumentParams params; // the ONE parameter set governing it
|
||||
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||
@@ -181,26 +149,25 @@ struct ComponentState {
|
||||
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
|
||||
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
|
||||
// user's chosen strike velocity across saves.
|
||||
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
|
||||
// the Sample-view preview button retains the user's chosen strike velocity across saves.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
|
||||
// exactly, so an older blob lifting to these plays byte-identically.
|
||||
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
|
||||
// reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
|
||||
// byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
|
||||
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
|
||||
// unity reproduces pre-master-gain output byte-identically.
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
|
||||
// voice sum — never per voice. Default unity reproduces pre-master-gain output
|
||||
// byte-identically.
|
||||
double masterGainLinear = 1.0;
|
||||
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||
// bank sample this instance plays (see the SampleRefs block above). setState decodes
|
||||
// straight from these; NO bridge/extension read is required for playback. A pre-v10
|
||||
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
|
||||
// once (then re-saves self-contained).
|
||||
// bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||
@@ -214,7 +181,7 @@ inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
|
||||
|
||||
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||
@@ -222,36 +189,36 @@ inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
|
||||
|
||||
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||
// in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||
// {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
|
||||
|
||||
// Selection + zones + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
// Selection + params + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
|
||||
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
|
||||
|
||||
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, params}.
|
||||
inline constexpr std::uint32_t kSelectionV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
@@ -266,16 +233,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
|
||||
// the instrument, never written back to the bank) — a single string id. serialize/
|
||||
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
|
||||
// without breaking already-saved instances.
|
||||
// The original v1 instance state was which bank sample it plays — a single string id.
|
||||
//
|
||||
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||
// for the v1->v3 back-compat lift in deserializeComponentState.
|
||||
// for the v1 back-compat lift in deserializeComponentState.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
@@ -286,5 +250,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <algorithm> // std::remove_if
|
||||
#include <cassert> // assert
|
||||
#include <utility> // std::move
|
||||
|
||||
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
|
||||
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
|
||||
// (start). The bank is never mutated.
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
|
||||
return rz;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map) {
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
|
||||
std::vector<std::string> ids;
|
||||
const auto addUnique = [&ids](const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
for (const std::string& have : ids) {
|
||||
if (have == id) return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
};
|
||||
addUnique(selectionId);
|
||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||
if (!selectionId.empty()) ids.push_back(selectionId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
DecodedPcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
@@ -230,7 +202,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
@@ -240,7 +212,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
PlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
@@ -256,130 +228,61 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
// --- The one parameter set ----------------------------------------------------
|
||||
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
|
||||
ResolvedCapture rs;
|
||||
rs.relativePath = ref.relativePath;
|
||||
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rs.keyTrack = params.keyTrack;
|
||||
rs.velocityCurve = params.velocityCurve;
|
||||
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
|
||||
// The bank is never mutated.
|
||||
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
|
||||
rs.startFrame = params.startPoint ? *params.startPoint : 0;
|
||||
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
|
||||
return rs;
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
|
||||
if (!sel) return std::nullopt;
|
||||
return resolveCapture(*sel, params);
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const SelectedSample* ref = findRef(refs, selectionId);
|
||||
if (ref == nullptr) return std::nullopt;
|
||||
return resolveCapture(*ref, params);
|
||||
}
|
||||
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
|
||||
assert(decoded.sampleRate > 0 &&
|
||||
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
|
||||
data.frames = std::move(decoded.monoFrames);
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(decoded.framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
data.sampleRate = decoded.sampleRate;
|
||||
data.rootNote = resolved.rootNote;
|
||||
data.loop = resolved.loop;
|
||||
data.startFrame = resolved.startFrame;
|
||||
data.keyTrack = resolved.keyTrack;
|
||||
data.velocityCurve = resolved.velocityCurve;
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(resolved.play, data.sampleRate);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// A sample lives in exactly one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
|
||||
continue;
|
||||
}
|
||||
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
|
||||
// fold — so the bank path and refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||
if (selectedId.empty() || map.zones.empty()) return false;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||
// there, so the map is left exactly as authored.
|
||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||
}
|
||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||
}
|
||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||
map.zones.resize(keptCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
|
||||
// The Browse modal is a full-window sheet drawn over the Sample face, so it reuses that
|
||||
// face's chrome metrics rather than minting its own — a divergent title height or pad would
|
||||
// make the sheet visibly not line up with what it covers.
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight
|
||||
#include "core/instrument/ui/sample_chrome.h" // kNavButtonWidth (the Back button's slot)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp to non-negative extents so a degenerate view can't produce inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
|
||||
out.canvas = Rect::ltrb(0, titleH, cw, ch);
|
||||
|
||||
// Button inset from the canvas top-left, clamped so it never overhangs a small view.
|
||||
const int bx = out.canvas.x + kButtonMargin;
|
||||
const int by = out.canvas.y + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
|
||||
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
|
||||
if (y < layout.canvas.y) return -1;
|
||||
if (y >= layout.canvas.bottom()) return -1;
|
||||
const int index = (y - layout.canvas.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
const int canvasW = std::max(0, canvas.width);
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.x, canvas.right() - splitW);
|
||||
|
||||
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
|
||||
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
|
||||
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
|
||||
out.addZoneButton =
|
||||
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
|
||||
out.zonePanel.y + addH);
|
||||
|
||||
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
|
||||
out.zonePanel.right(), out.zonePanel.bottom());
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
|
||||
top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.x || x >= list.right()) return -1;
|
||||
if (y < list.y || y >= list.bottom()) return -1;
|
||||
const int index = (y - list.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
|
||||
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
|
||||
top + kZoneRowHeight);
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.x || x >= area.right()) return ZoneHit{};
|
||||
if (y < area.y || y >= area.bottom()) return ZoneHit{};
|
||||
const int index = (y - area.y) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom()) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
|
||||
// order 0..6; a click left of the leftmost is the label ("select").
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kHeroMinHeight = 150; // elastic hero's floor
|
||||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
||||
constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip)
|
||||
|
||||
// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
|
||||
} // namespace
|
||||
|
||||
// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
|
||||
// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
|
||||
// window too short for the floor keeps the hero at its floor and clips lower bands.
|
||||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
||||
SampleBands b;
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
b.title = Rect::ltrb(0, 0, w, titleH);
|
||||
// Two nav buttons right-anchored in the title band (Browse then Zone).
|
||||
const int navTop = 2;
|
||||
const int navBot = (std::max)(navTop, titleH - 2);
|
||||
const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot);
|
||||
const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot);
|
||||
b.navBrowse = browse;
|
||||
b.navZone = zone;
|
||||
|
||||
int deckTop = h - kPad - deckH;
|
||||
int clusterTop = deckTop - kClusterHeight - 4;
|
||||
int heroBottom = clusterTop - 4;
|
||||
if (heroBottom - titleH < kHeroMinHeight) {
|
||||
heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below
|
||||
clusterTop = heroBottom + 4;
|
||||
deckTop = clusterTop + kClusterHeight + 4;
|
||||
}
|
||||
b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom);
|
||||
b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight);
|
||||
b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
|
||||
ClusterRects r;
|
||||
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
|
||||
const int stripBot = stripTop + kStripBandHeight;
|
||||
const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2;
|
||||
r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop,
|
||||
chanMono.x - kPad, curveTop + kCurveBtnSize);
|
||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
||||
r.curveBtn.x - kPad, stripBot);
|
||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + knobSize);
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom());
|
||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
||||
r.velCell.x - kPad, stripBot);
|
||||
r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot);
|
||||
return r;
|
||||
}
|
||||
|
||||
ChannelToggleRects channelToggleRects(const Rect& area) {
|
||||
const int top = area.y + (area.height - kChanSegH) / 2;
|
||||
const int right = area.right() - kPad;
|
||||
const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH);
|
||||
const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH);
|
||||
return {mono, stereo};
|
||||
}
|
||||
|
||||
Rect zoneContentArea(int w, int h) {
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
return Rect::ltrb(0, titleH, w, h);
|
||||
}
|
||||
|
||||
Rect zoneBackRect(int w, int h) {
|
||||
return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad,
|
||||
(std::max)(2, (std::min)(kTitleHeight, h) - 2));
|
||||
}
|
||||
|
||||
Rect zoneAddRect(const Rect& content) {
|
||||
return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96,
|
||||
content.y + 4 + 20);
|
||||
}
|
||||
|
||||
Rect zoneDeleteRect(const Rect& addR) {
|
||||
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
|
||||
}
|
||||
|
||||
// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
|
||||
Rect zonesStripArea(const Rect& content) {
|
||||
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
|
||||
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
|
||||
stripTop + kStripBandHeight);
|
||||
}
|
||||
|
||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
|
||||
Rect noteEntryFieldsArea(const Rect& content) {
|
||||
const int stripBottom = zonesStripArea(content).bottom();
|
||||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
|
||||
return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18);
|
||||
}
|
||||
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
||||
if (f < 0 || f > 2 || fields.width <= 0) return Rect{};
|
||||
const int segW = fields.width / 3;
|
||||
const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
||||
const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW;
|
||||
return Rect::ltrb(left, fields.y, right, fields.bottom());
|
||||
}
|
||||
|
||||
Rect zonesControlPanel(const Rect& content) {
|
||||
const Rect strip = zonesStripArea(content);
|
||||
const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||||
return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad,
|
||||
content.bottom() - 4);
|
||||
}
|
||||
|
||||
// Top-anchored; reserves a column at the panel's right for the curve-preview button so
|
||||
// no deck row starts inside it.
|
||||
Rect zonesDeckArea(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
|
||||
}
|
||||
|
||||
Rect zonesCurveButton(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -1,8 +1,9 @@
|
||||
// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
|
||||
// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
|
||||
// live here so they can be unit-tested outside the DAW.
|
||||
|
||||
#pragma once
|
||||
// editor_geometry.h — the shared geometry vocabulary for the VST3 editor's pure modules:
|
||||
// the one concrete `Rect` (aliased from core/ui) and its half-open `contains()`. Every
|
||||
// instrument UI module speaks these types, so they live in one place rather than each
|
||||
// module reaching into core/ui separately. The Sample face's own layout lives in
|
||||
// sample_bands (the band-stack allocator) and the per-band modules.
|
||||
|
||||
#include "core/ui/rect.h"
|
||||
|
||||
@@ -11,174 +12,4 @@ namespace reasampler::instrument::ui {
|
||||
using Rect = ::reasampler::ui::Rect;
|
||||
using ::reasampler::ui::contains;
|
||||
|
||||
// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
|
||||
// never yields a region spilling outside the surface.
|
||||
struct EditorLayout {
|
||||
Rect titleBar;
|
||||
Rect button;
|
||||
Rect canvas;
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the editor's top-level regions. Pure.
|
||||
EditorLayout layoutEditor(int w, int h);
|
||||
|
||||
enum class HitTarget {
|
||||
kNone,
|
||||
kButton,
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against a layout.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list ---------------------------------------------------
|
||||
//
|
||||
// A vertical stack of fixed-height rows below the title bar; clicking a row selects that
|
||||
// sample. Pure geometry only — the shell draws names and routes the click.
|
||||
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
|
||||
// beyond what the canvas can show are still computed (the shell clips at paint time); a
|
||||
// negative index yields an empty rect.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
|
||||
// the list.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor ------------------------------------------------------------
|
||||
//
|
||||
// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
|
||||
// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
|
||||
// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
|
||||
// carries nudge/delete mini-buttons (LICE has no native numeric entry field).
|
||||
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
|
||||
|
||||
// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base;
|
||||
Rect sampleList; // LEFT column
|
||||
Rect zonePanel; // RIGHT column
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// Bank-sample row a click lands on inside the left list, or -1 outside it.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields: a label on the left, then seven fixed-width
|
||||
// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
|
||||
// means the click missed a control (e.g. the label) — the shell may still treat that as
|
||||
// "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
kLowUp,
|
||||
kHighDown,
|
||||
kHighUp,
|
||||
kRootDown,
|
||||
kRootUp,
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// Which zone row (or -1) and which field within it a click landed on. A click on
|
||||
// "Add Zone" is reported separately by addZoneHitTest.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
|
||||
// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
|
||||
// click left of those slots is {index, kZoneNone} (the label area — "select").
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample / Zone face layout ------------------------------------------------
|
||||
//
|
||||
// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
|
||||
// both derive every rect from these formulas so they can never drift; the shell only
|
||||
// draws + routes. The Browse-modal layout lives in browser_scroll (its search box
|
||||
// height feeds it).
|
||||
|
||||
inline constexpr int kPad = 8;
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||||
|
||||
// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
|
||||
// HERO (absorbs all height left after the fixed bands, floored), the root+preview
|
||||
// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
|
||||
// window shorter than the hero floor clips the lower bands past the window bottom.
|
||||
struct SampleBands {
|
||||
Rect title;
|
||||
Rect navBrowse;
|
||||
Rect navZone;
|
||||
Rect hero; // waveform + envelope overlay
|
||||
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
|
||||
Rect deck;
|
||||
};
|
||||
SampleBands computeSampleBands(int w, int h, int deckH);
|
||||
|
||||
// Cluster sub-rects: the root strip keeps the left side at remainder width; the right
|
||||
// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
|
||||
// Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
|
||||
// depend on knob_deck.
|
||||
struct ClusterRects {
|
||||
Rect rootStrip;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the curve-preview popup
|
||||
};
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
|
||||
|
||||
// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
|
||||
struct ChannelToggleRects {
|
||||
Rect mono;
|
||||
Rect stereo;
|
||||
};
|
||||
ChannelToggleRects channelToggleRects(const Rect& area);
|
||||
|
||||
// Zone-view content area: the whole window below the title band.
|
||||
Rect zoneContentArea(int w, int h);
|
||||
|
||||
// Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
|
||||
Rect zoneBackRect(int w, int h);
|
||||
|
||||
// "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
|
||||
// when a zone is selected).
|
||||
Rect zoneAddRect(const Rect& content);
|
||||
Rect zoneDeleteRect(const Rect& addR);
|
||||
|
||||
// Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
|
||||
// horizontally.
|
||||
Rect zonesStripArea(const Rect& content);
|
||||
|
||||
// Numeric-entry field row area inside the Zones legend, and the rect of field `f`
|
||||
// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
|
||||
// index yields an empty rect.
|
||||
Rect noteEntryFieldsArea(const Rect& content);
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f);
|
||||
|
||||
// Per-zone parameter panel below the strip + legend, running to the content bottom; the
|
||||
// knob-deck area within it (a right column reserved for the curve-preview button); and
|
||||
// that button's rect (right-anchored at the panel top).
|
||||
Rect zonesControlPanel(const Rect& content);
|
||||
Rect zonesDeckArea(const Rect& content);
|
||||
Rect zonesCurveButton(const Rect& content);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -15,7 +15,7 @@ int clampNote(int n) {
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
|
||||
// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
|
||||
// span's left uses floor(low) and its right uses floor(high+1), tiling adjacent spans
|
||||
// without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
@@ -44,31 +44,19 @@ EmbedLayout layoutEmbed(int w, int h) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
const Rect& band = layout.keymap;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
if (lo > hi) lo = hi; // defensive: a malformed span collapses rather than inverts
|
||||
|
||||
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
|
||||
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
|
||||
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
|
||||
}
|
||||
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y) {
|
||||
if (zoneCount <= 0 || zones == nullptr) return -1;
|
||||
if (!contains(layout.keymap, x, y)) return -1;
|
||||
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
|
||||
for (int i = 0; i < zoneCount; ++i) {
|
||||
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
|
||||
if (contains(r, x, y)) return i;
|
||||
}
|
||||
return -1; // on the band but on an uncovered key
|
||||
}
|
||||
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level) {
|
||||
const Rect& band = layout.levelBand;
|
||||
if (band.width <= 0 || band.height <= 0) return Rect{};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// bitmap + mouse coords) into these functions.
|
||||
//
|
||||
// A single compact band REAPER draws inline in the track/mixer control panel via the
|
||||
// Cockos embedded-UI surface: each performance zone as a horizontal segment across the
|
||||
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
|
||||
// at the bottom. Interaction is zone selection only — no editing.
|
||||
// Cockos embedded-UI surface: the loaded capture across the keyboard span (MIDI 0..127
|
||||
// mapped to the strip width) with its root marked, plus a thin activity level band at the
|
||||
// bottom. Read-only — the strip displays, it never edits.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -19,18 +19,10 @@ inline constexpr int kEmbedKeyCount = 128;
|
||||
inline constexpr int kEmbedLevelBandHeight = 4;
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6;
|
||||
|
||||
// One zone rendered on the strip: its inclusive MIDI key range — the minimal projection
|
||||
// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
|
||||
// low <= high; layout clamps defensively regardless.
|
||||
struct EmbedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
};
|
||||
|
||||
// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
|
||||
// the surface.
|
||||
struct EmbedLayout {
|
||||
Rect keymap; // top: zone-segment band
|
||||
Rect keymap; // top: keyboard-span band
|
||||
Rect levelBand; // bottom: level/activity indicator
|
||||
};
|
||||
|
||||
@@ -39,16 +31,11 @@ struct EmbedLayout {
|
||||
// kEmbedKeymapMinHeight); the keymap takes the rest.
|
||||
EmbedLayout layoutEmbed(int w, int h);
|
||||
|
||||
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
|
||||
// overlap. Notes clamp to [0,127] and low clamps to <= high.
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
|
||||
// zones overlap on a key, the first covering zone in order wins — mirroring the sampler
|
||||
// core's first-match Keymap::resolve, so selection agrees with playback.
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y);
|
||||
// Horizontal sub-rect of the keymap band for the inclusive key span [lowNote, highNote].
|
||||
// Spans the half-open pixel range so adjacent spans tile without a gap or overlap. Notes
|
||||
// clamp to [0,127] and low clamps to <= high. The loaded capture uses the full span; a
|
||||
// single-key span (low == high) is the root marker.
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
|
||||
// whose width is level * band width.
|
||||
|
||||
@@ -15,7 +15,7 @@ int clampNote(int n) {
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
@@ -62,41 +62,6 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
|
||||
const Rect bar = zoneBarRect(layout, lowNote, highNote);
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width;
|
||||
// A narrow bar has no body: split at the midpoint, low edge wins the tie.
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.x + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
}
|
||||
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
|
||||
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
|
||||
return ZoneGrab::kBody;
|
||||
}
|
||||
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
|
||||
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
|
||||
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
|
||||
}
|
||||
return ZoneBarHit{}; // on the band but on no bar
|
||||
}
|
||||
|
||||
bool isNaturalKey(int note) {
|
||||
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
|
||||
static constexpr bool kNatural[12] = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
|
||||
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
|
||||
// and marshals mouse events into these functions.
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the editor's keyboard strip.
|
||||
// Mirror of embed_strip/mode_switch; the shell draws and marshals mouse events into these
|
||||
// functions.
|
||||
//
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
|
||||
// embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
|
||||
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
|
||||
// bar with edge-grab resize handles + a body move-handle).
|
||||
// embed_strip uses). The loaded capture responds across that whole span, so the strip's
|
||||
// job is the root marker: click a key, or drag the marker, to set the root.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -17,10 +16,6 @@ namespace reasampler::instrument::ui {
|
||||
// stay independent.
|
||||
inline constexpr int kStripKeyCount = 128;
|
||||
|
||||
// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
|
||||
// body move-handle (both edges win their halves).
|
||||
inline constexpr int kStripEdgeGrabWidth = 6;
|
||||
|
||||
// The keys band takes the whole strip area today; clamped so a degenerate size never
|
||||
// yields an inverted rect.
|
||||
struct StripLayout {
|
||||
@@ -37,44 +32,15 @@ int keyLeftX(const StripLayout& layout, int note);
|
||||
// Half-open rect of a single key `note`, clamped to [0,127].
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// Root-marker rect for the single-capture fast path; equivalent to
|
||||
// keyRect(layout, rootNote) but named so the intent reads at the call site.
|
||||
// Root-marker rect; equivalent to keyRect(layout, rootNote) but named so the intent reads
|
||||
// at the call site.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// MIDI note a point (x, y) lands on, or -1 outside the keys band.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
|
||||
// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
|
||||
// moves the whole span, kNone means the grab missed the bar.
|
||||
enum class ZoneGrab {
|
||||
kNone,
|
||||
kLowEdge,
|
||||
kHighEdge,
|
||||
kBody,
|
||||
};
|
||||
|
||||
// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body); the low edge wins a tie at the exact
|
||||
// midpoint.
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
|
||||
|
||||
// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
|
||||
// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
|
||||
// order wins.
|
||||
struct ZoneBarHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneGrab grab = ZoneGrab::kNone;
|
||||
};
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
|
||||
// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
|
||||
// edges with the same delta to preserve span), and root-marker drag.
|
||||
// clamped to [0,127]. The one arithmetic behind the root-marker drag.
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
|
||||
|
||||
// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// sample_bands.cpp — see sample_bands.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
const int deckH = std::max(0, deckHeight);
|
||||
|
||||
SampleBands b;
|
||||
const int chromeH = std::min(kTitleHeight + kChromeRowHeight, ch);
|
||||
b.chrome = Rect::ltrb(0, 0, cw, chromeH);
|
||||
|
||||
// Decks are bottom-anchored so the deck row sits on the window edge at any height; the
|
||||
// waveform absorbs whatever is left. When that leaves less than the two-lane floor the
|
||||
// FLOOR WINS and the deck band is pushed past the window bottom (clipped) rather than
|
||||
// squeezing the waveform into an unreadable sliver.
|
||||
int deckTop = ch - kPad - deckH;
|
||||
int waveTop = chromeH + kBandGap;
|
||||
int waveBottom = deckTop - kBandGap;
|
||||
if (waveBottom - waveTop < kWaveformMinHeight) {
|
||||
waveBottom = waveTop + kWaveformMinHeight;
|
||||
deckTop = waveBottom + kBandGap;
|
||||
}
|
||||
|
||||
b.waveform = Rect::ltrb(kPad, waveTop, std::max(kPad, cw - kPad), waveBottom);
|
||||
b.decks = Rect::ltrb(kPad, deckTop, std::max(kPad, cw - kPad), deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
WaveformLanes waveformLanes(const Rect& waveform, bool stereo) {
|
||||
WaveformLanes lanes;
|
||||
if (waveform.empty()) return lanes;
|
||||
if (!stereo) {
|
||||
lanes.upper = waveform; // one lane; `lower` stays empty
|
||||
return lanes;
|
||||
}
|
||||
// Split the usable height evenly, giving the seam to the gap. An odd remainder goes to
|
||||
// the upper (left) lane so the two lanes never disagree about the seam row.
|
||||
const int usable = std::max(0, waveform.height - kLaneGap);
|
||||
const int lowerH = usable / 2;
|
||||
const int upperH = usable - lowerH;
|
||||
const int upperBottom = waveform.y + upperH;
|
||||
lanes.upper = Rect::ltrb(waveform.x, waveform.y, waveform.right(), upperBottom);
|
||||
lanes.lower = Rect::ltrb(waveform.x, upperBottom + kLaneGap, waveform.right(),
|
||||
waveform.bottom());
|
||||
return lanes;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
// sample_bands.h — THE band-stack allocator for the Sample face: the one module that owns
|
||||
// the editor's vertical inventory. Three bands, top to bottom — CHROME (toolbar + control
|
||||
// row), WAVEFORM (elastic, sized to hold two stacked channel lanes), DECKS (the knob-deck
|
||||
// row). Everything else in the editor fills a band it is handed; nothing else allocates
|
||||
// vertical space, so a band's owner can re-lay its interior without moving its neighbours.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Shared outer inset every band honours horizontally.
|
||||
inline constexpr int kPad = 8;
|
||||
|
||||
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
||||
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kChromeRowHeight = 52;
|
||||
|
||||
// Waveform band floor: two stacked lanes plus the seam between them. The band never shrinks
|
||||
// below this — a window too short for it clips the bands beneath instead, so the waveform
|
||||
// stays a usable two-lane surface at every size.
|
||||
inline constexpr int kLaneMinHeight = 74;
|
||||
inline constexpr int kLaneGap = 2;
|
||||
inline constexpr int kWaveformMinHeight = 2 * kLaneMinHeight + kLaneGap;
|
||||
|
||||
// Vertical seam between adjacent bands.
|
||||
inline constexpr int kBandGap = 4;
|
||||
|
||||
// The vertical inventory. Bands never overlap and are returned top-to-bottom; a band may be
|
||||
// empty() on a degenerate window, in which case its owner draws and hit-tests nothing.
|
||||
struct SampleBands {
|
||||
Rect chrome; // full width: toolbar row + control row
|
||||
Rect waveform; // kPad-inset, elastic, >= kWaveformMinHeight
|
||||
Rect decks; // kPad-inset, bottom-anchored, height `deckHeight`
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
||||
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||
// the deck band is exactly as tall as its content. Pure.
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
||||
|
||||
// The waveform band's two channel lanes: L above R, separated by kLaneGap. In mono only
|
||||
// `upper` is populated (it takes the whole band) and `lower` is empty — a mono capture has
|
||||
// no second lane to draw, and overlays that ride the waveform draw ONCE across the whole
|
||||
// band in either mode, never per lane.
|
||||
struct WaveformLanes {
|
||||
Rect upper;
|
||||
Rect lower; // empty() in mono
|
||||
};
|
||||
WaveformLanes waveformLanes(const Rect& waveform, bool stereo);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,71 @@
|
||||
// sample_chrome.cpp — see sample_chrome.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_chrome.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight / kChromeRowHeight
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kStripBandHeight = 40; // the root/piano strip's own height inside the row
|
||||
|
||||
// The control row's fixed right-anchored run, right to left.
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
|
||||
} // namespace
|
||||
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
ChromeRects r;
|
||||
if (chrome.empty()) return r;
|
||||
|
||||
const int titleH = std::min(kTitleHeight, chrome.height);
|
||||
r.toolbar = Rect::ltrb(chrome.x, chrome.y, chrome.right(), chrome.y + titleH);
|
||||
r.controls = Rect::ltrb(chrome.x, r.toolbar.bottom(), chrome.right(), chrome.bottom());
|
||||
|
||||
const int navTop = r.toolbar.y + 2;
|
||||
const int navBot = std::max(navTop, r.toolbar.bottom() - 2);
|
||||
r.navBrowse = Rect::ltrb(std::max(chrome.x, chrome.right() - kPad - kNavButtonWidth),
|
||||
navTop, std::max(chrome.x, chrome.right() - kPad), navBot);
|
||||
|
||||
if (r.controls.empty()) return r;
|
||||
const Rect& row = r.controls;
|
||||
|
||||
// Vertically centre the two heights the row uses: the tall strip band (which the preview
|
||||
// button and velocity cell align to) and the smaller square/segment controls.
|
||||
const int stripTop = row.y + (row.height - kStripBandHeight) / 2;
|
||||
const int stripBot = stripTop + kStripBandHeight;
|
||||
|
||||
const int chanTop = row.y + (row.height - kChanSegH) / 2;
|
||||
const int chanRight = row.right() - kPad;
|
||||
r.chanStereo = Rect::ltrb(chanRight - kChanSegW, chanTop, chanRight, chanTop + kChanSegH);
|
||||
r.chanMono = Rect::ltrb(r.chanStereo.x - kChanSegW, chanTop, r.chanStereo.x,
|
||||
chanTop + kChanSegH);
|
||||
|
||||
const int curveTop = row.y + (row.height - kCurveBtnSize) / 2;
|
||||
r.curveBtn = Rect::ltrb(r.chanMono.x - kPad - kCurveBtnSize, curveTop,
|
||||
r.chanMono.x - kPad, curveTop + kCurveBtnSize);
|
||||
|
||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
||||
r.curveBtn.x - kPad, stripBot);
|
||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + knobSize);
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(),
|
||||
r.velCell.bottom());
|
||||
|
||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
||||
r.velCell.x - kPad, stripBot);
|
||||
// Remainder width; clamped so a narrow window collapses the strip rather than inverting it.
|
||||
r.rootStrip = Rect::ltrb(row.x + kPad, stripTop,
|
||||
std::max(row.x + kPad, r.preview.x - kPad), stripBot);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
// sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row
|
||||
// (title + Browse) over the control row (root/piano strip, preview trigger, preview-velocity
|
||||
// knob cell, curve-preview button, Mono|Stereo toggle). Reads the band rect the allocator
|
||||
// hands it (sample_bands) and never allocates vertical space of its own.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
|
||||
|
||||
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
|
||||
// derive them differently. The control row's right-anchored run is fixed-width (preview,
|
||||
// velocity cell, curve button, channel toggle) and the root strip takes the remainder, so
|
||||
// the strip grows with the window.
|
||||
struct ChromeRects {
|
||||
Rect toolbar; // full-width top row
|
||||
Rect navBrowse; // right-anchored in the toolbar
|
||||
Rect controls; // full-width second row
|
||||
Rect rootStrip; // remainder-width, left
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the velocity-curve popup
|
||||
Rect chanMono;
|
||||
Rect chanStereo;
|
||||
};
|
||||
|
||||
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
Reference in New Issue
Block a user