Merge Θ-W1-T1: retire the zone system, re-seam the engine and Sample face into bands

This commit is contained in:
2026-07-30 08:44:42 -04:00
67 changed files with 5583 additions and 8178 deletions
+112 -76
View File
@@ -551,11 +551,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid)
# ---------------------------------------------------------------------------
# 2v) Pure sampler_core library — NO VST3, NO REAPER, NO SWELL. The HEART of the
# Phase S MIDI-playback instrument (S3 / D3): polyphonic voice allocation with
# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with
# (note, velocity) -> zone resolution, and repitch/interpolation from a root note
# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book,
# tested hard outside any host. Lives under core/instrument/engine/ but
# MIDI-playback instrument (D3): polyphonic voice allocation with bounded stealing,
# the three envelope evaluators, and repitch/interpolation from a root note with
# loop-point-aware sustain over ONE loaded capture. The mirror of bank_model / peaks /
# bank_book, tested hard outside any host. Lives under core/instrument/engine/ but
# links NEITHER SDK — the plain-data boundary is enforced structurally: the test
# target below links only sampler_core (+ its peaks dep for the AudioSample alias,
# the one house precedent wav_codec also relies on). The VST3 shell (shell/instrument/
@@ -573,12 +572,19 @@ target_link_libraries(pitch_shift PUBLIC peaks)
# velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse
# map). NO VST3/REAPER/SWELL/vendor and DELIBERATELY no editor_geometry (its hit-test takes an
# explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive
# dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a
# VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
# dependency on the editor's layout types. play_params.h carries one (SampleData holds the
# curve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp)
target_include_directories(velocity_curve PUBLIC src)
add_library(sampler_core STATIC src/core/instrument/engine/sampler_core.cpp)
# Two TUs on the engine's own responsibility seam: voice.cpp is the per-NOTE half (note-on
# setup, the Preserve ring prime, legato retune), voice_engine.cpp the note routing /
# allocation / stealing / mono stack / panic / block render. The per-SAMPLE render half is
# inline in voice.h (with the envelope evaluators in envelopes.h) precisely so this TU
# boundary costs the hot path nothing — see voice.h's header.
add_library(sampler_core STATIC
src/core/instrument/engine/voice.cpp
src/core/instrument/engine/voice_engine.cpp)
target_include_directories(sampler_core PUBLIC src)
target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve)
@@ -817,48 +823,67 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests)
# ---------------------------------------------------------------------------
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge
# read marshalling — GetProjExtState result decode + the ONE grow-loop retry
# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge
# read AND the extension's persist/usage_scan ext-state reads (hence linked into
# reaper_reasampler too). Both are unit-tested outside the DAW;
# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified.
# editor_geometry: the shared geometry vocabulary every instrument UI module speaks
# (the `Rect` alias + contains()) — header-only, hence INTERFACE; the Sample face's
# own layout lives in sample_bands/sample_chrome below. bridge_marshal: the REAPER
# VST-host bridge read marshalling — GetProjExtState result decode + the ONE
# grow-loop retry policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the
# VST bridge read AND the extension's persist/usage_scan ext-state reads (hence
# linked into reaper_reasampler too); unit-tested outside the DAW, while the VST3
# shell (shell/instrument/*) that draws/routes/invokes is DAW-verified.
# ---------------------------------------------------------------------------
add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp)
target_include_directories(editor_geometry PUBLIC src)
add_library(editor_geometry INTERFACE)
target_include_directories(editor_geometry INTERFACE src)
# sample_bands — THE band-stack allocator: the ONE module that owns the Sample face's
# vertical inventory (chrome / two-lane waveform / decks) 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. NEITHER SDK.
add_library(sample_bands STATIC src/core/instrument/ui/sample_bands.cpp)
target_include_directories(sample_bands PUBLIC src)
target_link_libraries(sample_bands PUBLIC editor_geometry)
# 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). Reads
# the band rect from sample_bands; owns no vertical inventory of its own. NEITHER SDK.
add_library(sample_chrome STATIC src/core/instrument/ui/sample_chrome.cpp)
target_include_directories(sample_chrome PUBLIC src)
target_link_libraries(sample_chrome PUBLIC sample_bands)
add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp)
target_include_directories(bridge_marshal PUBLIC src)
# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the
# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill.
# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside
# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed
# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect.
# embed_strip (Phase S6) — PURE layout for the embedded TCP/MCP strip: the 128-key span ->
# key-span rects (the loaded capture's full span, and its root marker) and the level-band
# fill. Read-only, so no hit-test. Unit-tested outside the DAW, while the embed shell
# (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed messages (the paint
# bitmap) into it. Links editor_geometry for the shared Rect.
add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp)
target_include_directories(embed_strip PUBLIC src)
target_link_libraries(embed_strip PUBLIC editor_geometry)
# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the
# instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse,
# NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned
# keymap builds, and the refs/performance resolution. Links the three pure modules it
# composes — bank_book (shared JSON), wav_codec (shared WAV parse), and sampler_core (the
# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor)
# does the bridge read + file I/O off the audio thread, then calls these; the process
# callback stays allocation-free. The ComponentState codec is component_state_io below.
# NOT a second parser), interleaved->mono downmix (the channel policy), the one parameter
# set's override-beats-intrinsic fold, and the SampleData build. Links the pure modules it
# composes — bank_book (shared JSON), wav_codec (shared WAV parse), velocity_curve (the
# curve field play_params.h carries) — and NEITHER SDK. NOT the voice engine: since the
# build's product is plain SampleData, the engine's object code is no longer a dependency.
# The VST3 shell (reasampler_processor) does the bridge read + file I/O off the audio
# thread, then calls these; the process callback stays allocation-free. The ComponentState
# codec is component_state_io below.
add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp)
target_include_directories(sample_map PUBLIC src)
target_link_libraries(sample_map PUBLIC bank_book wav_codec sampler_core)
target_link_libraries(sample_map PUBLIC bank_book wav_codec velocity_curve peaks)
# component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState
# ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every
# lift preserved byte-identically). Split so the codec — which grows on every envelope
# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links
# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and
# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters
# the extension binary. Its own test target linking exactly these is the structural proof.
# ENVELOPE + params-payload binary codec (envelope v1..v11, params payload v1..v8, every
# lift preserved byte-identically; v1..v7 are the retired zone lists, read via the
# adopt-zone-one migration). Split so the codec — which grows on every envelope bump and is
# shared with the EXTENSION's preset-blob path (instrument_drop) — links WITHOUT the voice
# engine: its deps are velocity_curve (the curve field) and master_gain (the v8 wire cap)
# only; sampler_core/pitch_shift object code never enters the extension binary. Its own test
# target linking exactly these is the structural proof.
add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp)
target_include_directories(component_state_io PUBLIC src)
target_link_libraries(component_state_io PUBLIC velocity_curve master_gain)
@@ -872,10 +897,9 @@ add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp)
target_include_directories(capture_browser PUBLIC src)
target_link_libraries(capture_browser PUBLIC editor_geometry)
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects +
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, and the
# drag-delta note resolver for the editor's keyboard strip (root display + root-set). The
# mirror of embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp)
target_include_directories(keyboard_strip PUBLIC src)
target_link_libraries(keyboard_strip PUBLIC editor_geometry)
@@ -908,13 +932,7 @@ target_link_libraries(bank_sync PRIVATE wire)
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp)
target_include_directories(browser_scroll PUBLIC src)
target_link_libraries(browser_scroll PUBLIC capture_browser)
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
add_library(note_entry STATIC src/core/instrument/map/note_entry.cpp)
target_include_directories(note_entry PUBLIC src)
target_link_libraries(browser_scroll PUBLIC capture_browser sample_chrome)
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
@@ -937,8 +955,8 @@ target_include_directories(trigger_seam PUBLIC src)
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
# duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps.
# The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect.
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored
# AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the one parameter
# set's stored AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp)
target_include_directories(envelope_overlay PUBLIC src)
target_link_libraries(envelope_overlay PUBLIC editor_geometry)
@@ -974,9 +992,16 @@ target_link_libraries(curve_popup PUBLIC editor_geometry)
add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
target_include_directories(master_gain PUBLIC src)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
# sample_bands: the band-stack allocator's vertical inventory, asserted as pure geometry
# (chrome / two-lane waveform / deck row) independent of any paint call — the contract the
# band owners downstream read.
add_executable(sample_bands_tests tests/test_sample_bands.cpp)
target_link_libraries(sample_bands_tests PRIVATE sample_bands)
add_test(NAME sample_bands_tests COMMAND sample_bands_tests)
add_executable(sample_chrome_tests tests/test_sample_chrome.cpp)
target_link_libraries(sample_chrome_tests PRIVATE sample_chrome)
add_test(NAME sample_chrome_tests COMMAND sample_chrome_tests)
add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp)
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal)
@@ -1026,11 +1051,6 @@ add_executable(browser_scroll_tests tests/test_browser_scroll.cpp)
target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests)
# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry.
add_executable(note_entry_tests tests/test_note_entry.cpp)
target_link_libraries(note_entry_tests PRIVATE note_entry)
add_test(NAME note_entry_tests COMMAND note_entry_tests)
# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
# value<->pixel mapping the editor parameter surface draws + routes against.
add_executable(param_slider_tests tests/test_param_slider.cpp)
@@ -1253,16 +1273,26 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
src/shell/instrument/reasampler_processor.cpp
src/shell/instrument/processor_state.cpp
src/shell/instrument/processor_reload.cpp
# The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state,
# param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform;
# the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry
# + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU).
# The editor family, split on the Sample face's BAND axis: session/bridge state,
# param plumbing + the shared band-layout resolve, then paint and input in matching
# sets — dispatch, chrome, waveform, decks — plus the two band-independent surfaces
# (the Browse modal and the velocity-curve popup) and the platform/window TU. The
# layout math itself is PURE (core/instrument/ui/sample_bands + sample_chrome +
# browser_scroll). Shared internals: editor_internal.h (no TU).
src/shell/instrument/editor_session.cpp
src/shell/instrument/editor_controls.cpp
src/shell/instrument/editor_paint_sample.cpp
src/shell/instrument/editor_paint_browse_zone.cpp
src/shell/instrument/editor_input_sample.cpp
src/shell/instrument/editor_input_browse_zone.cpp
src/shell/instrument/editor_paint.cpp
src/shell/instrument/editor_paint_chrome.cpp
src/shell/instrument/editor_paint_waveform.cpp
src/shell/instrument/editor_paint_deck.cpp
src/shell/instrument/editor_paint_browse.cpp
src/shell/instrument/editor_paint_curve.cpp
src/shell/instrument/editor_input.cpp
src/shell/instrument/editor_input_chrome.cpp
src/shell/instrument/editor_input_waveform.cpp
src/shell/instrument/editor_input_deck.cpp
src/shell/instrument/editor_input_browse.cpp
src/shell/instrument/editor_input_curve.cpp
src/shell/instrument/editor_platform.cpp
src/shell/instrument/reasampler_embed.cpp
src/shell/instrument/reaper_bridge.cpp
@@ -1278,16 +1308,21 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
${LICE_SRC}
)
# editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure
# bank->keymap mapping + state (de)ser the processor drives off the audio thread;
# linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model,
# peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile /
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src)
# gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...).
# bank -> one-capture resolve + SampleData build the processor drives off the audio
# thread; linking it pulls its pure deps (bank_book, wav_codec, velocity_curve, peaks)
# transitively — deliberately NOT sampler_core (the voice engine). capture_paths: the
# shared M4 path resolution (resolveBankFile / projectDirOfRpp) the bridge + processor
# use. Its PUBLIC include dir (src)
# gives the shell TUs their headers (ext_keys.h, bank_book.h, voice_engine.h, ...).
# embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals
# into; it links editor_geometry transitively (shared Rect).
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so
# the instrument reads the SAME namespace the extension writes; its PUBLIC include dir
# (build/generated) carries version_generated.h for the channel bit.
# sampler_core: the voice engine the processor drives (sample_map no longer pulls it —
# its build yields plain SampleData — so the module links it directly.)
# sample_bands + sample_chrome: the band-stack allocator the Sample face's three band
# TUs read, and the chrome band's interior geometry.
# capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip
# geometry the capture-first editor draws + hit-tests against; both link editor_geometry
# transitively (shared Rect).
@@ -1297,11 +1332,11 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
# processor's off-thread poll runs; links assignment_request transitively (the decoded
# request it consumes) — the same key the extension writes, shared via the pure module.
# browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
# browser_scroll + param_slider (S12 + S15/S16 control surfaces): the pure scroll/search
# geometry over the capture browser, and the control-panel layout + slider/toggle
# value<->pixel mapping the editor's parameter surface draws + routes against.
# browser_scroll pulls capture_browser transitively; param_slider links editor_geometry /
# the stdlib only. All engine-free, DAW-verified in the shell.
# theme + component_geometry + bank_grid: the Phase L (L1) draw-kit's PURE deps (L3). The
# kit draws every editor/embed surface by palette ROLE via draw_kit.cpp (compiled into the
# module above): theme supplies role->KitColor + spectralColor, component_geometry the
@@ -1317,8 +1352,9 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# sample_usage (pS-usage): the usage-record wire + publish plan the processor's
# reloadInstrument publishes through the bridge (the one sanctioned VST-side write).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider
sampler_core sample_map component_state_io capture_paths embed_strip app_version
capture_browser keyboard_strip sample_bands sample_chrome
waveform_view bank_sync browser_scroll param_slider
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck curve_popup master_gain sample_usage file_bytes)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
+60 -40
View File
@@ -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,25 +197,29 @@ and `envelope_edit` in Modules below.
### `engine/`
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (132, 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 (132, 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 FritschCarlson 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).
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
### `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 +234,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
+266
View File
@@ -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
@@ -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
-956
View File
@@ -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ₙ*(1w) + ref*w = out*(11) + 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
-485
View File
@@ -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ₙ*(1w) + 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
+174
View File
@@ -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
+441
View File
@@ -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ₙ*(1w) + ref*w = out*(11) + 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ₙ*(1w) + 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
+272
View File
@@ -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
+148
View File
@@ -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
+226 -225
View File
@@ -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;
}
+127 -164
View File
@@ -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
-111
View File
@@ -1,111 +0,0 @@
// note_entry.cpp — see note_entry.h.
#include "core/instrument/map/note_entry.h"
#include <algorithm>
#include <cctype>
namespace reasampler::instrument::map {
namespace {
char asciiUpper(char c) {
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
std::string trim(const std::string& s) {
std::size_t a = 0;
std::size_t b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
int clampNote(long long n) {
if (n < 0) return 0;
if (n > 127) return 127;
return static_cast<int>(n);
}
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
int letterSemitone(char up) {
switch (up) {
case 'C': return 0;
case 'D': return 2;
case 'E': return 4;
case 'F': return 5;
case 'G': return 7;
case 'A': return 9;
case 'B': return 11;
default: return -1;
}
}
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
const int base = letterSemitone(asciiUpper(s[i]));
if (base < 0) return std::nullopt; // not a letter -> not a note name
++i;
int semitone = base;
// Optional accidental(s): # / b only (not 's'/'f').
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
if (s[i] == '#') ++semitone;
else --semitone;
++i;
}
// The octave: an optional sign then digits, running to the end.
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
int octave = 0;
bool anyDigit = false;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
octave = octave * 10 + (s[i] - '0');
anyDigit = true;
}
if (!anyDigit) return std::nullopt;
if (neg) octave = -octave;
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
return clampNote(note);
}
std::optional<int> parseInteger(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
bool neg = false;
if (s[i] == '+' || s[i] == '-') {
neg = (s[i] == '-');
++i;
}
if (i >= s.size()) return std::nullopt;
long long v = 0;
for (; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
v = v * 10 + (s[i] - '0');
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
}
if (neg) v = -v;
return clampNote(v);
}
} // namespace
std::optional<int> parseNoteEntry(const std::string& text) {
const std::string s = trim(text);
if (s.empty()) return std::nullopt;
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
if (auto n = parseInteger(s)) return n;
}
return parseNoteName(s);
}
} // namespace reasampler::instrument::map
-18
View File
@@ -1,18 +0,0 @@
// note_entry — parse + clamp for direct numeric/note-name entry of a zone's low/high/root
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
//
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
// rejecting; unparseable input returns nullopt (shell keeps the old value).
#pragma once
#include <optional>
#include <string>
namespace reasampler::instrument::map {
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map
+61 -158
View File
@@ -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
+88 -144
View File
@@ -1,10 +1,11 @@
#pragma once
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
// here. Links bank_book, wav_codec, and sampler_core (all pure).
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
// NOT the voice engine: the build's product is plain SampleData.
#include <cstdint>
#include <optional>
@@ -12,7 +13,7 @@
#include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map {
@@ -29,7 +30,7 @@ struct SelectedSample {
int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // hasLoop=false when the bank left it empty
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
// the GA channel-mode auto-default skips it
// the channel-mode auto-default skips it
};
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
@@ -60,8 +61,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
// Consequence: a sample deleted from the bank no longer silences an instance that carries
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// the file yields the defined no-play).
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
@@ -74,10 +73,9 @@ using SampleRefs = std::vector<SampleRefEntry>;
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
// Every bank sample id this instance plays: the selection (when set) + each zone's
// sampleId, de-duplicated, selection first then map order.
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// Every bank sample id this instance plays. One capture = at most one id; the list form is
// kept because the refs-table helpers below are id-set operations.
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
@@ -123,10 +121,10 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// --- Stored (wall-clock SECONDS) play params ----------------------------------
//
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
// keymap build. Quantities anchored to the source file's timeline (start point, loop
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
// unchanged (TriggerParams reused verbatim).
// build. Quantities anchored to the source file's timeline (start point, loop points,
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
// (TriggerParams reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
@@ -162,10 +160,10 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak
};
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
struct ZonePlaySeconds {
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
// from the engine-facing PlayParams (frames).
struct PlaySeconds {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
@@ -173,46 +171,28 @@ struct ZonePlaySeconds {
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// picked single capture plays under the same default engine as a zone would. Resolves the
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- Performance map (the instrument's OWN state) ---------------
// --- The instrument's ONE parameter set (its OWN state) -----------------------
//
// The performance map is the keymap the user authors IN the instrument: several bank
// samples zoned across the keyboard, each with a key range and a root note. A performance
// choice, so it lives in the instrument (VST3 component state), never written back to the
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
// + this map into a sampler_core Keymap.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
// read position are facts about the file, but the instrument may override them per zone
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
// the effective ResolvedZone.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
int highNote = 127; // inclusive
// One loaded capture, one set of playback parameters governing it across the whole
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
// never written back to the bank. Pure value type: names no sample (the ComponentState's
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
// the pure build stitches the decoded frames + this set into one SampleData.
//
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
// read position are facts about the file, but the instrument may override them without
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
// ResolvedCapture.
struct InstrumentParams {
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
@@ -223,116 +203,80 @@ struct PerformanceZone {
double keyTrack = 1.0;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// in Voice::start.
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
// Voice::start.
VelocityCurve velocityCurve = VelocityCurve::flat();
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// to exactly these defaults on read.
ZonePlaySeconds play;
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
PlaySeconds play;
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution — first zone in order wins (mirrors the core's first-match
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
//
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
// control edit. Loading a different sample used to change only the selection id, leaving
// the previous sample's full-range zone in the map — and since zone resolution is
// first-match in order, that stale zone shadowed every later one forever: the engine kept
// playing the old sample while the editor drew the new one's zone. This function is called
// at every selection-change site so the zone the editor draws is the zone the engine plays.
//
// Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
// first-match order is load-bearing there — untouched, false (the Sample face never
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
// (params preserved); drop the rest. A selection with no zone yet empties the map.
// Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// an id) — this is the id resolved against the live bank.
struct ResolvedZone {
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
// (which holds optional overrides) — this is the parameter set folded against the capture.
struct ResolvedCapture {
std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
PlaySeconds play; // stored SECONDS; resolved to frames at build
};
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// cleanly — not an error, not silence for the whole map — and reported here so the editor
// can flag/prune it.
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
// drift.
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// -> empty result.
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
// empty selection, or a stale id -> nullopt.
//
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// (both share foldZone, so the drift test keeps the shared fold honest).
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map);
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
// resolveCapture, so the drift test keeps the shared fold honest).
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
const std::string& selectionId,
const InstrumentParams& params);
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map);
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
const std::string& selectionId,
const InstrumentParams& params);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
struct DecodedZonePcm {
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
struct DecodedPcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
// `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (average all source channels).
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// Empty/zero-channel input -> empty frames (caller plays silence).
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
// plays across the whole keyboard, repitched from the effective root. A second channel is
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
// unplayable SampleData (silence, never a crash).
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
// so both artifacts share the codec while only the VST links the voice engine.
+5 -1
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
// window within min/max bounds. A title row sits over the curve box. The curve box rect
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
// formula, so the popup editor and the Zone-panel inline editor share coordinates.
// formula.
#pragma once
-273
View File
@@ -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
+5 -174
View File
@@ -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
+3 -15
View File
@@ -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{};
+9 -22
View File
@@ -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.
+3 -2
View File
@@ -3,8 +3,9 @@
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
//
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
// every paint), so a node drag and a slider edit are two views on one source of truth.
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
// parameter set every paint), so a node drag and a slider edit are two views on one source of
// truth.
//
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
+3 -2
View File
@@ -1,7 +1,8 @@
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
// the polyline plus a handle at each node (envelope_edit does the hit-test).
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
// hit-test).
#pragma once
+1 -36
View File
@@ -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] = {
+8 -42
View File
@@ -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
+54
View File
@@ -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
+53
View File
@@ -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
+71
View File
@@ -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
+34
View File
@@ -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
+8 -8
View File
@@ -8,8 +8,8 @@ two small identity/helper headers this directory owns outright
(`reasampler_vst.h`, `editor_internal.h`).
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `zone_params.h`, `editor_geometry`,
`keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `note_entry`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
@@ -78,7 +78,7 @@ scattered `#ifdef`s in the VST shell, except the one described below).
- No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- The pure core stays REAPER-free *and* VST3-free — the voice engine / envelope /
keymap / repitch module takes no VST3 or REAPER type at its boundary; the shell
repitch module takes no VST3 or REAPER type at its boundary; the shell
marshals. Any VST3 or REAPER type leaking into `core/instrument` is a bug.
- Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
headers before use.
@@ -86,17 +86,17 @@ scattered `#ifdef`s in the VST shell, except the one described below).
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family (Q-W2v split), included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the former god-TU's anonymous-namespace helpers that more than one split TU needs. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/editor_internal.h`'s own header comment and body.)*
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
## Gotchas
- `editor_internal.h` is include-only — it has no TU of its own and must never become
a public seam; only the eight `reasampler_editor` face-axis TUs include it.
a public seam; only the `reasampler_editor` band-axis TUs include it.
- The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
`reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID.
- The UID selection `#ifdef` in `reasampler_vst.h` is the one deliberate exception to
+49 -68
View File
@@ -1,8 +1,8 @@
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
// Value logic only — no painting, no window plumbing.
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// applyControl — seconds/fraction/frames <-> normalized 0..1), the knob-deck group
// descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam
// converter). Value logic only — no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h"
@@ -14,14 +14,20 @@
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
#include "core/util/clamp01.h"
#include "shell/instrument/editor_internal.h" // DeckGroup ids
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
using instrument::ui::EnvMode; // envelope_overlay's mode enum
using instrument::ui::computeSampleBands;
using instrument::ui::chromeRects;
using instrument::ui::deckHeight;
using instrument::ui::kDeckKnobSize;
using instrument::ui::kPad;
using instrument::engine::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear;
@@ -30,7 +36,7 @@ using util::clamp01;
namespace {
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
@@ -42,7 +48,18 @@ constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling
} // namespace
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
// chrome interior, and the deck descriptors can never be derived three different ways.
// The deck's own wrapped height is the only interior measurement the allocator needs.
FaceLayout fl;
fl.deckDescs = deckGroupDescs(params_.play);
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
return fl;
}
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
@@ -74,7 +91,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
}
}
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
int segment) const {
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
@@ -134,12 +151,10 @@ double ReaSamplerEditor::previewVelocity01() const {
return static_cast<double>(processor_->previewVelocity()) / 127.0;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
// neighbouring groups.
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
// The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
// reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
// flip repopulates in place and never reflows the neighbouring groups.
std::vector<DeckGroupDesc> out;
{
DeckGroupDesc amp;
@@ -179,14 +194,6 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv));
}
return out;
}
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
// appended here, not in zoneDeckGroupDescs.
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
{
DeckGroupDesc voice;
voice.id = kGroupVoice;
@@ -206,22 +213,22 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySecond
return out;
}
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack:
return clamp01(zone.keyTrack / kKeyTrackMax);
return clamp01(params_.keyTrack / kKeyTrackMax);
case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
case ParamControl::kMasterGain:
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
default:
return controlValue(id, zone.play);
return controlValue(id, params_.play);
}
}
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
if (!processor_) return;
norm = clamp01(norm);
if (id == -2) {
@@ -247,15 +254,15 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
return;
default:
applyZoneControl(zoneIndex, id, norm, 0);
applyParamControl(id, norm, 0);
return;
}
}
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
std::string ReaSamplerEditor::deckValueLabel(int id) const {
char buf[24];
buf[0] = '\0';
const ZonePlaySeconds& play = zone.play;
const PlaySeconds& play = params_.play;
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
@@ -282,13 +289,13 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack:
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
case ParamControl::kVoiceCount:
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
default:
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
break;
}
return std::string(buf);
@@ -309,7 +316,7 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
return b;
}
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
@@ -320,7 +327,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
// span (the trigger-seam converter, pack direction). startFrame is the zone's effective start
// span (the trigger-seam converter, pack direction). startFrame is the effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
@@ -332,7 +339,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
std::int64_t startFrame, ZonePlaySeconds& play) const {
std::int64_t startFrame, PlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
@@ -342,7 +349,7 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the trigger-seam converter, unpack direction). startFrame is the
// zone's effective start point so the frame denominator matches the voice's actual
// effective start point so the frame denominator matches the voice's actual
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
// so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
@@ -353,39 +360,13 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
}
}
PerformanceZone ReaSamplerEditor::popupZone() const {
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
if (view_ == View::kZone && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
return map_.zones[static_cast<std::size_t>(selectedZone_)];
}
return effectiveSampleZone();
}
int ReaSamplerEditor::popupZoneIndex() {
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
// Zone surface never materializes (the button only shows for an explicit selection); the
// Sample face finds-or-materializes the picked id's one-zone site.
if (view_ == View::kZone) {
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
? selectedZone_
: -1;
}
return ensureSampleZone();
}
#ifdef _WIN32
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1.
z.keyTrack = clamp01(value) * kKeyTrackMax;
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
params_.keyTrack = clamp01(value) * kKeyTrackMax;
} else {
applyControl(id, z.play, value, segment);
applyControl(id, params_.play, value, segment);
}
}
#endif // _WIN32
} // namespace reasampler::vst
+142
View File
@@ -0,0 +1,142 @@
// editor_input.cpp — the ReaSamplerEditor's input dispatch and drag-state machine: the
// mouse-down routing (curve popup first, then Browse or the three bands in order), the
// onMouseMove drag router, the release commit, and the hover resolver. The per-band
// branches live in the editor_input_<band> TUs; this TU only sequences them.
// Windows-only. All hit-test math is pure; this family routes and mutates editor state.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (view_ == View::kBrowse) {
mouseDownBrowse(w, h, x, y);
return;
}
// The curve popup is modal over the face — while open it owns every left-click.
if (handlePopupMouseDown(w, h, x, y)) return;
// Band order matters only where bands can overlap on a degenerate window; each branch
// reports whether it consumed the click so the next band gets a clean shot.
const FaceLayout fl = faceLayout(w, h);
if (mouseDownChrome(fl, x, y)) return;
if (selectedId_.empty()) return; // empty state — chrome nav only
if (mouseDownDeck(fl, x, y)) return;
mouseDownWaveform(fl, x, y);
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
dragCurY_ = y;
// The three band-free drags resolve without a layout pass at all.
switch (drag_) {
case DragKind::kDeckKnob: dragDeck(x, y); return;
case DragKind::kScrollThumb: dragBrowse(x, y); return;
case DragKind::kCurveNode: dragCurve(x, y); return;
default: break;
}
RECT rc{};
GetClientRect(childHwnd_, &rc);
const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top);
if (drag_ == DragKind::kRootMarker) {
dragChrome(fl, x, y);
} else {
dragWaveform(fl, x, y);
}
}
void ReaSamplerEditor::onMouseUp(int x, int y) {
// Release a held preview note first (the preview button is a momentary key: note-off on up).
// This runs regardless of drag state — the preview press does not start a drag.
if (previewingNote_ >= 0) {
if (processor_) processor_->previewNoteOff(previewingNote_);
previewingNote_ = -1;
invalidate();
}
if (drag_ == DragKind::kNone) return;
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
curvePointIndex_ = -1;
// A scrollbar drag is transient UI (no parameter change), and the processor-side knobs
// (the preview-velocity -2 sentinel, voice count, master gain) are per-instance settings
// that don't reload the instrument. Master gain is an atomic the audio thread reads
// directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
paramId == static_cast<int>(ParamControl::kVoiceCount))
processor_->setVoiceCount(voiceCount_);
invalidate();
return;
}
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
// its amp keeps the last clamped drag value).
if (kind == DragKind::kCurveNode && curveIdx >= 0) {
const bool off = x < curveRect.x - kCurveDragOffMargin ||
x > curveRect.right() + kCurveDragOffMargin ||
y < curveRect.y - kCurveDragOffMargin ||
y > curveRect.bottom() + kCurveDragOffMargin;
if (off) {
params_.velocityCurve.deletePoint(static_cast<std::size_t>(curveIdx));
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
}
}
commitAndReload();
}
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free). Mirrors onMouseDown's routing order, but read-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
HoverTarget h; // kNone by default
if (view_ == View::kBrowse) {
h = hoverBrowse(w, hgt, x, y);
} else if (curvePopupOpen_) { // modal over the face
h = hoverCurvePopup(w, hgt, x, y);
} else {
const FaceLayout fl = faceLayout(w, hgt);
h = hoverChrome(fl, x, y);
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y);
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,174 @@
// editor_input_browse.cpp — the Browse modal's input: the click branch (tabs, cards,
// select-then-confirm, scroll-thumb grab, search focus), the thumb drag, the wheel scroll,
// the type-to-filter keystrokes, and the modal's hover. Also carries the degraded
// drop affordance (an OS drop is never ingested). Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::map;
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
// Cancel/Back: discard the pending pick, return to Sample unchanged.
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.confirm, x, y)) {
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
if (!browsePendingId_.empty()) loadSelection(browsePendingId_);
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
searchFocused_ = false;
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) {
activeFilterBankId_ = (tab == 0) ? std::string()
: banks_[static_cast<std::size_t>(tab - 1)].id;
rebuildVisible();
invalidate();
return;
}
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height > 0 &&
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
drag_ = DragKind::kScrollThumb;
dragStartY_ = y;
dragStartScrollOffset_ = scrollOffset_;
return;
}
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
const std::string id = visible_[static_cast<std::size_t>(card)].id;
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
loadSelection(id);
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
view_ = View::kSample;
invalidate();
} else {
browsePendingId_ = id;
lastBrowseClickCard_ = card;
invalidate();
}
return;
}
lastBrowseClickCard_ = -1;
}
void ReaSamplerEditor::dragBrowse(int x, int y) {
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
// window recomputes at paint from scrollOffset_.
(void)x;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const BrowseModal bm = computeBrowseModal(rc.right - rc.left, rc.bottom - rc.top);
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
dragStartScrollOffset_, y - dragStartY_);
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
int y) const {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
if (contains(bm.confirm, x, y)) return {HoverKind::kBrowseConfirm, -1};
if (contains(bm.search, x, y)) return {HoverKind::kSearchBox, -1};
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) return {HoverKind::kFilterTab, tab};
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) return {HoverKind::kCard, card};
return {};
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
// positive delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowse) return;
const int rows = delta / 120;
if (rows == 0) return;
scrollOffset_ -= rows * kBrowserCardHeight;
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
invalidate();
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// The curve popup: Esc dismisses (checked first — the popup is modal over the face, and
// the Browse search cannot hold focus under it).
if (curvePopupOpen_ && ch == 27) {
curvePopupOpen_ = false;
invalidate();
return;
}
// Type-to-filter search. Only when the search box has focus (a click focuses it).
// Backspace deletes; a printable ASCII char appends; the visible list recomposes (bank
// filter, then search).
if (view_ != View::kBrowse || !searchFocused_) return;
if (ch == 8) { // backspace
if (!searchQuery_.empty()) searchQuery_.pop_back();
} else if (ch == 27) { // escape clears + defocuses
searchQuery_.clear();
searchFocused_ = false;
} else if (ch >= 32 && ch < 127) {
searchQuery_.push_back(static_cast<char>(ch));
} else {
return; // ignore other control chars
}
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
rebuildVisible();
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
// decays it to 0.
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
dropHintTicks_ = 6;
invalidate();
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -1,437 +0,0 @@
// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
// input + the hover resolver: hover resolution across all three faces, the Browse picker's
// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the
// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck
// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes,
// and the degraded drop affordance. Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry)
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::map;
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
HoverTarget h; // kNone by default
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
if (view_ == View::kBrowse) {
const BrowseModal bm = computeBrowseModal(w, hgt);
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
else {
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
const int card = (tab >= 0)
? -1
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
else if (card >= 0) h = {HoverKind::kCard, card};
}
} else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
if (contains(pl.close, x, y)) {
h = {HoverKind::kPopupClose, -1};
} else if (contains(pl.curveBox, x, y)) {
// A curve node under the pointer lights accent-hot.
const int idx =
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
}
} else if (view_ == View::kZone) {
const Rect back = zoneBackRect(w, hgt);
const Rect content = zoneContentArea(w, hgt);
Rect addR = zoneAddRect(content);
Rect delR = zoneDeleteRect(addR);
if (contains(back, x, y)) {
h = {HoverKind::kBack, -1};
} else if (contains(addR, x, y)) {
h = {HoverKind::kAddZone, -1};
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
// The per-zone knob deck + the mini curve-preview button (the Sample deck's hover
// grammar — knobs light + swap label->value).
if (contains(zonesCurveButton(content), x, y)) {
h = {HoverKind::kCurveButton, -1};
} else {
const ZonePlaySeconds& play =
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x,
deckArea.y, deckArea.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
}
}
} else { // Sample view (home)
const PerformanceZone zone = effectiveSampleZone();
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
const SampleBands bands =
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
if (contains(bands.navBrowse, x, y)) {
h = {HoverKind::kNavBrowse, -1};
} else if (contains(bands.navZone, x, y)) {
h = {HoverKind::kNavZone, -1};
} else if (selectedId_.empty() && map_.zones.empty()) {
// Empty state — no interactive surfaces beyond the nav.
} else {
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
else if (contains(bands.deck, x, y)) {
// A deck knob/toggle under the pointer: knobs light + swap label->value.
const DeckLayout dl =
layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
}
}
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the
// dispatch).
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
// Cancel/Back: discard the pending pick, return to Sample unchanged.
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.confirm, x, y)) {
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
if (!browsePendingId_.empty()) {
loadSelection(browsePendingId_);
}
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
searchFocused_ = false;
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
const int bx = x - bm.content.x;
const int by = y - bm.content.y;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) {
activeFilterBankId_ = (tab == 0) ? std::string()
: banks_[static_cast<std::size_t>(tab - 1)].id;
rebuildVisible();
invalidate();
return;
}
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height > 0 &&
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
drag_ = DragKind::kScrollThumb;
dragStartY_ = y;
dragStartScrollOffset_ = scrollOffset_;
return;
}
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
const std::string id = visible_[static_cast<std::size_t>(card)].id;
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
loadSelection(id);
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
view_ = View::kSample;
invalidate();
} else {
browsePendingId_ = id;
lastBrowseClickCard_ = card;
invalidate();
}
return;
}
lastBrowseClickCard_ = -1;
return;
}
// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the
// Zone surface too).
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
if (handlePopupMouseDown(w, h, x, y)) return;
const Rect back = zoneBackRect(w, h);
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
const Rect content = zoneContentArea(w, h);
Rect addR = zoneAddRect(content);
if (contains(addR, x, y)) {
// Add a narrow default zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
// already exists, select it rather than appending a duplicate (mirrors the upsert the
// root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one
// octave centred on the bank root, clamped to [0,127]) so the new zone is immediately
// "authored" (narrow) and survives reconcileSingleCaptureZones without being treated
// as a Sample-face full-range zone.
std::string seed = !selectedId_.empty() ? selectedId_
: (!visible_.empty() ? visible_.front().id : std::string());
if (seed.empty()) return;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
selectedZone_ = i;
invalidate();
return;
}
}
// Look up the seed's root note from the browser list (absent root defaults to 60).
int seedRoot = 60;
for (const SampleChoice& sc : samples_) {
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
}
const int lo = (std::max)(0, seedRoot - 6);
const int hi = (std::min)(127, seedRoot + 5);
PerformanceZone z;
z.sampleId = seed;
z.lowNote = lo;
z.highNote = hi;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
Rect delR = zoneDeleteRect(addR);
if (selectedZone_ >= 0 && contains(delR, x, y)) {
map_.zones.erase(map_.zones.begin() + selectedZone_);
selectedZone_ = -1;
commitAndReload();
return;
}
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
// selected zone's root.
const Rect stripArea = zonesStripArea(content);
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int lx = x - stripArea.x;
const int ly = y - stripArea.y;
std::vector<int> lows, highs;
lows.reserve(map_.zones.size());
highs.reserve(map_.zones.size());
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
highs.empty() ? nullptr : highs.data(),
static_cast<int>(map_.zones.size()), lx, ly);
if (hit.zoneIndex >= 0) {
selectedZone_ = hit.zoneIndex;
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
dragStartX_ = x;
dragStartLow_ = z.lowNote;
dragStartHigh_ = z.highNote;
dragStartMap_ = map_;
switch (hit.grab) {
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
default: drag_ = DragKind::kNone; break;
}
invalidate();
return;
}
// A bare key-click inside the strip sets the selected zone's root override.
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
const int note = keyAtPoint(sl, lx, ly);
if (note >= 0) {
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
commitAndReload();
}
return;
}
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
// zone is selected. entryText_ starts empty (the user types the full value).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect fields = noteEntryFieldsArea(content);
for (int f = 0; f < 3; ++f) {
if (contains(noteEntryFieldRect(fields, f), x, y)) {
entryField_ = f;
entryText_.clear();
invalidate();
return;
}
}
}
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
// Zone surface has no single-capture fallback — that lives on the Sample face).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
if (contains(zonesCurveButton(content), x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y,
deckArea.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
// final edit committed at once (the deck precedent). No per-instance ids reach
// here — VOICE/MASTER are not in the zone group set.
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
commitAndReload();
return;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
// Sample deck's guard, mirrored.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
// Grab-anchored vertical drag: live-drag the map, commit on release.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = selectedZone_;
dragStartMap_ = map_;
dragKnobStartValue_ = deckControlNorm(
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
}
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
// positive delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowse) return;
const int rows = delta / 120;
if (rows == 0) return;
scrollOffset_ -= rows * kBrowserCardHeight;
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
invalidate();
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
// or the Zone surface; opening it clears any note-entry focus, and the Browse search
// cannot hold focus under it).
if (curvePopupOpen_ && ch == 27) {
curvePopupOpen_ = false;
invalidate();
return;
}
// Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
// focused, owns the keystrokes).
if (view_ == View::kZone && entryField_ >= 0) {
if (ch == 13) { // Enter: parse + commit
if (auto note = parseNoteEntry(entryText_)) {
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
else z.rootOverride = *note;
commitAndReload();
}
}
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 27) { // Escape cancels
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 8) { // backspace
if (!entryText_.empty()) entryText_.pop_back();
invalidate();
} else if (ch >= 32 && ch < 127) {
entryText_.push_back(static_cast<char>(ch));
invalidate();
}
return;
}
// Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then
// search).
if (view_ != View::kBrowse || !searchFocused_) return;
if (ch == 8) { // backspace
if (!searchQuery_.empty()) searchQuery_.pop_back();
} else if (ch == 27) { // escape clears + defocuses
searchQuery_.clear();
searchFocused_ = false;
} else if (ch >= 32 && ch < 127) {
searchQuery_.push_back(static_cast<char>(ch));
} else {
return; // ignore other control chars
}
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
rebuildVisible();
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
// decays it to 0.
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
dropHintTicks_ = 6;
#ifdef _WIN32
invalidate();
#endif
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,115 @@
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
// the preview-velocity knob grab, the curve-button summon, the channel toggle, and the
// root-marker grab plus its live drag. Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root marker)
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) {
// Open the Browse modal; seed its pending pick from the loaded id so the current
// capture reads as pre-selected.
browsePendingId_ = selectedId_;
lastBrowseClickCard_ = -1;
view_ = View::kBrowse;
invalidate();
return true;
}
if (selectedId_.empty()) return false; // empty state — nav only
// Preview-trigger button: fire the loaded capture at its root through the voice engine
// (momentary — note-on on press, note-off on release).
if (contains(cr.preview, x, y)) {
const int note = effectiveRoot();
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
previewingNote_ = note;
processor_->previewNoteOn(note);
invalidate();
return true;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
drag_ = DragKind::kDeckKnob;
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
dragKnobStartValue_ = previewVelocity01();
dragStartX_ = x;
dragStartY_ = y;
invalidate();
return true;
}
// The mini curve-preview button: summon the popup editor.
if (contains(cr.curveBtn, x, y)) {
curvePopupOpen_ = true;
invalidate();
return true;
}
if (contains(cr.chanMono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
invalidate();
return true;
}
if (contains(cr.chanStereo, x, y)) {
channelMode_ = ChannelMode::Stereo;
processor_->setChannelMode(ChannelMode::Stereo);
invalidate();
return true;
}
// The root strip: grab the root marker. A plain click sets the root to the clicked key
// (applied below as the first delta==0 move).
if (cr.rootStrip.width > 0) {
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
if (note >= 0) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
dragStartParams_ = params_;
onMouseMove(x, y); // apply the click as the first delta==0 set
return true;
}
}
// A click on the control row's background is consumed so it can't fall through to a
// band the user cannot see under the chrome.
return contains(cr.controls, x, y);
}
void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
(void)y;
const Rect& stripArea = fl.chrome.rootStrip;
if (stripArea.width <= 0) return;
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
params_.rootOverride = resolveDragNote(sl, dragStartRoot_, x - dragStartX_);
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
int y) const {
const ChromeRects& cr = fl.chrome;
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1};
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
return {};
}
} // namespace reasampler::vst
#endif // _WIN32
+127
View File
@@ -0,0 +1,127 @@
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click
// delete, and the popup's hover. Band-independent (the sheet floats over the whole face).
// Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
#include "shell/instrument/editor_internal.h" // curveBoxFromRect
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
// While open the sheet is modal over the face — it owns every left-click. Close click /
// outside-wash click dismiss (outside only when no drag is in flight); in-box clicks
// route to the curve machinery; anything else on the sheet is swallowed.
if (!curvePopupOpen_) return false;
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) {
curvePopupOpen_ = false;
invalidate();
return true;
}
if (contains(pl.curveBox, x, y)) {
handleCurveMouseDown(pl.curveBox, x, y);
return true;
}
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
curvePopupOpen_ = false;
invalidate();
}
return true;
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
int idx = params_.velocityCurve.pointAtPixel(box, x, y);
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) commitAndReload();
return;
}
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
dragStartParams_ = params_;
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
// endpoint. A ring click can still grab an existing node (handled above).
if (idx < 0) {
const bool inBox = (x >= box.left && x < box.left + box.width &&
y >= box.top && y < box.top + box.height);
if (inBox) {
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
idx = static_cast<int>(params_.velocityCurve.addPoint(p.velocity, p.amp));
}
}
if (idx < 0) return; // ring click with no node hit — nothing to grab
drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx;
dragStartCurve_ = params_.velocityCurve; // AFTER the add — resolvePointDrag's delta base
dragCurveRect_ = r;
dragStartX_ = x;
dragStartY_ = y;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
void ReaSamplerEditor::dragCurve(int x, int y) {
// Resolve the grabbed control point from the pixel delta through the pure inverse map
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute
// delta — the mirror of the envelope-node drag). Live feedback only; commit on release.
if (curvePointIndex_ < 0) return;
params_.velocityCurve = VelocityCurve::resolvePointDrag(
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_);
invalidate();
}
void ReaSamplerEditor::onMouseRDown(int x, int y) {
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
// and drag-off remain as landed alternates. Commits immediately through the same path as
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
if (drag_ != DragKind::kNone) return;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = params_.velocityCurve.pointAtPixel(box, x, y);
if (idx < 0) return;
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
}
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
int y) const {
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
if (!contains(pl.curveBox, x, y)) return {};
// A curve node under the pointer lights accent-hot.
const int idx =
params_.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
if (idx < 0) return {};
return {HoverKind::kCurveNode, idx};
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,99 @@
// editor_input_deck.cpp — the DECKS band's input: toggles (committed at once, a discrete
// final edit), knob grabs (grab-anchored vertical drag, committed on release), the live
// knob-drag resolution, and the band's hover. Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return false;
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
switch (static_cast<ParamControl>(hit.id)) {
case ParamControl::kVoiceMode: {
// Processor-side per-instance param: live setter (engine rebuild via the
// drain-slot swap — tails survive), local snapshot in step.
const VoiceMode m = (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
if (m != voiceMode_) {
voiceMode_ = m;
processor_->setVoiceMode(m);
}
invalidate();
break;
}
case ParamControl::kMonoTrigger: {
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
const MonoTrigger t =
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
if (t != monoTrigger_) {
monoTrigger_ = t;
processor_->setMonoTrigger(t);
}
invalidate();
break;
}
default:
// Parameter-set toggles (play mode / pitch engine / pitch-env enable).
applyParamControl(hit.id, 0.0, hit.segment);
commitAndReload();
break;
}
return true;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !params_.play.pitchEnv.enabled) return true;
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragKnobStartValue_ = deckControlNorm(hit.id);
// Processor-side knobs (voice count / master gain) are transient live writes with no
// parameter-set mutation, so they need no rollback snapshot.
dragStartParams_ = params_;
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
// The deck band swallows its own clicks either way — no fall-through to the waveform.
return true;
}
void ReaSamplerEditor::dragDeck(int x, int y) {
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
// value at grab (up = increase), so the value tracks relative motion and never jumps on
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
(void)x;
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
invalidate();
}
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
int y) const {
const Rect& band = fl.bands.decks;
if (!contains(band, x, y)) return {};
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
const DeckHit dh = hitTestDeck(dl, x, y);
if (dh.kind == DeckHitKind::None) return {};
return {HoverKind::kControl, dh.id};
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -1,583 +0,0 @@
// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state
// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches
// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the
// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve
// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the
// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and
// mutates editor state only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
using namespace reasampler::instrument::map;
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
// The curve popup: while open the sheet is modal over its host face — the Sample home or
// the Zone surface — it owns every left-click. Close click / outside-wash click dismiss
// (outside only when no drag is in flight); in-box clicks route to the shared curve
// machinery against popupZoneIndex(); anything else on the sheet is swallowed.
if (!curvePopupOpen_) return false;
const CurvePopupLayout pl = computeCurvePopup(w, h);
if (contains(pl.close, x, y)) {
curvePopupOpen_ = false;
invalidate();
return true;
}
if (contains(pl.curveBox, x, y)) {
const int zi = popupZoneIndex();
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
return true;
}
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
curvePopupOpen_ = false;
invalidate();
}
return true;
}
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
int idx = z.velocityCurve.pointAtPixel(box, x, y);
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zoneIndex;
commitAndReload();
}
return;
}
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
dragStartMap_ = map_;
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint.
// A ring click can still grab an existing node (handled above); only add is box-gated.
if (idx < 0) {
const bool inBox = (x >= box.left && x < box.left + box.width &&
y >= box.top && y < box.top + box.height);
if (inBox) {
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
}
}
if (idx < 0) return; // ring click with no node hit — nothing to grab
drag_ = DragKind::kCurveNode;
curvePointIndex_ = idx;
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
dragCurveRect_ = r;
dragCurveZone_ = zoneIndex;
dragStartX_ = x;
dragStartY_ = y;
selectedZone_ = zoneIndex;
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
// Browse modal: the face branch lives in editor_input_browse_zone.
if (view_ == View::kBrowse) {
mouseDownBrowse(w, h, x, y);
return;
}
// Sample home.
if (view_ == View::kSample) {
// The curve popup: while open the sheet is modal — it owns every left-click.
if (handlePopupMouseDown(w, h, x, y)) return;
const PerformanceZone probeZone = effectiveSampleZone();
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
const SampleBands bands =
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
if (contains(bands.navBrowse, x, y)) {
// Open the Browse modal; seed its pending pick from the loaded id so the current
// capture reads as pre-selected.
browsePendingId_ = selectedId_;
lastBrowseClickCard_ = -1;
view_ = View::kBrowse;
invalidate();
return;
}
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
// Preview-trigger button: fire the loaded capture at its root through the voice engine
// (momentary — note-on on press, note-off on release).
if (contains(cr.preview, x, y)) {
const int note = effectiveRoot();
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
previewingNote_ = note;
processor_->previewNoteOn(note);
invalidate();
return;
}
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
// jumps the value; the delta from the grab point maps via knobDragValue.
if (contains(cr.velCell, x, y)) {
drag_ = DragKind::kDeckKnob;
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
dragParamZone_ = -1;
dragKnobStartValue_ = previewVelocity01();
dragStartX_ = x;
dragStartY_ = y;
invalidate();
return;
}
// The mini curve-preview button: summon the popup editor.
if (contains(cr.curveBtn, x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
// Channel toggle.
if (contains(chan.mono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
invalidate();
return;
}
if (contains(chan.stereo, x, y)) {
channelMode_ = ChannelMode::Stereo;
processor_->setChannelMode(ChannelMode::Stereo);
invalidate();
return;
}
// The knob deck: toggles commit at once (a discrete, final edit); knobs start a
// grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to
// the hero/markers).
if (contains(bands.deck, x, y)) {
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
bands.deck.width);
const DeckHit hit = hitTestDeck(dl, x, y);
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
switch (static_cast<ParamControl>(hit.id)) {
case ParamControl::kVoiceMode: {
// Processor-side per-instance param: live setter (engine rebuild via
// the drain-slot swap — tails survive), local snapshot in step.
const VoiceMode m =
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
if (m != voiceMode_) {
voiceMode_ = m;
processor_->setVoiceMode(m);
}
invalidate();
break;
}
case ParamControl::kMonoTrigger: {
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
const MonoTrigger t =
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
if (t != monoTrigger_) {
monoTrigger_ = t;
processor_->setMonoTrigger(t);
}
invalidate();
break;
}
default: {
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
// materialize the one-zone site, apply, commit.
const int zi = ensureSampleZone();
if (zi >= 0) {
applyZoneControl(zi, hit.id, 0.0, hit.segment);
selectedZone_ = zi;
commitAndReload();
}
break;
}
}
return;
}
if (hit.kind == DeckHitKind::Knob) {
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
const bool pitchEnvKnob =
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
// Processor-side knobs: transient live writes, no map edit, no reload.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = -1;
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
} else {
// Zone-param knobs: live-drag the map, commit on release.
const int zi = ensureSampleZone();
if (zi < 0) return;
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = zi;
selectedZone_ = zi;
dragStartMap_ = map_;
dragKnobStartValue_ =
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
}
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
return;
}
// Hero waveform: envelope nodes first, then the wave markers.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = bands.hero;
if (frames > 0) {
const double rate = liveSampleRate();
if (rate > 0.0) {
const PerformanceZone zone = effectiveSampleZone();
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
if (nh.hit) {
drag_ = DragKind::kEnvNode;
envNode_ = nh.node;
dragStartX_ = x;
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartMap_ = map_;
return; // node moves once the cursor drags
}
}
const SetupMarkers m = pickedMarkers(frames);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
if (hit >= 0) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartMap_ = map_;
return;
}
}
// Fenced root strip: grab the root marker (remainder-width).
if (cr.rootStrip.width > 0) {
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
if (note >= 0) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
dragStartMap_ = map_;
onMouseMove(x, y); // apply the click as the first delta==0 set
return;
}
}
return;
}
// Zone surface: the face branch lives in editor_input_browse_zone.
mouseDownZone(w, h, x, y);
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
dragCurY_ = y;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const int w = rc.right - rc.left;
const int h = rc.bottom - rc.top;
const int dx = x - dragStartX_;
if (drag_ == DragKind::kDeckKnob) {
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
// value at grab (up = increase), so the value tracks relative motion and never jumps
// on grab. Live feedback; zone-param commits land on WM_LBUTTONUP.
const int dy = y - dragStartY_;
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
invalidate();
return;
}
// The Sample bands derive from the deck height (mode-independent width math). Hoisted
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
if (drag_ == DragKind::kRootMarker) {
// The fenced root strip on the Sample cluster band. Setting the root materializes a
// full-keyboard zone carrying the override on the picked id — upsert by id so a
// repeated drag edits the same zone rather than stacking duplicates.
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int note = resolveDragNote(sl, dragStartRoot_, dx);
bool found = false;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.rootOverride = note;
selectedZone_ = i;
found = true;
break;
}
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = note;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
}
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return;
}
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab
// (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of
// the marker path).
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const int dy = y - dragStartY_;
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
totalSeconds, envClampBounds(), dx, dy);
const int zi = ensureSampleZone();
if (zi >= 0) {
unpackEnvelope(edited, frames, dragStartFrame_,
map_.zones[static_cast<std::size_t>(zi)].play);
selectedZone_ = zi;
}
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
if (drag_ == DragKind::kCurveNode) {
// Resolve the grabbed control point from the pixel delta through the pure inverse map
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box
// (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
// commit lands on WM_LBUTTONUP.
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
if (curvePointIndex_ < 0) return;
const int dy = y - dragStartY_;
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
VelocityCurve::resolvePointDrag(dragStartCurve_,
static_cast<std::size_t>(curvePointIndex_),
curveBoxFromRect(dragCurveRect_), dx, dy);
invalidate();
return;
}
if (drag_ == DragKind::kWaveMarker) {
// Resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap it
// against the decoded PCM, apply the inter-marker clamps, and write the override live.
const Rect waveArea = bands.hero;
const std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
// frames — no host types, no file I/O.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
// map hides controls after the first marker drag on the single-capture face).
selectedZone_ = upsertPickedOverride(m);
invalidate();
return;
}
if (drag_ == DragKind::kScrollThumb) {
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only
// happens in the Browse modal (the sole card grid). The visible-card window recomputes
// at paint from scrollOffset_.
const int dyThumb = y - dragStartY_;
const BrowseModal bm = computeBrowseModal(w, h);
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
dragStartScrollOffset_, dyThumb);
invalidate();
return;
}
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (drag_ == DragKind::kZoneLow) {
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
} else if (drag_ == DragKind::kZoneHigh) {
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
} else if (drag_ == DragKind::kZoneBody) {
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
const int span = dragStartHigh_ - dragStartLow_;
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
else { z.lowNote = newLow; z.highNote = newHigh; }
}
invalidate();
}
void ReaSamplerEditor::onMouseUp(int x, int y) {
// Release a held preview note first (the preview button is a momentary key: note-off on up).
// This runs regardless of drag state — the preview press does not start a drag.
if (previewingNote_ >= 0) {
if (processor_) processor_->previewNoteOff(previewingNote_);
previewingNote_ = -1;
invalidate();
}
if (drag_ == DragKind::kNone) return;
const DragKind kind = drag_;
const int paramId = dragParamId_;
const int curveIdx = curvePointIndex_;
const int curveZone = dragCurveZone_;
const Rect curveRect = dragCurveRect_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragParamZone_ = -1;
curvePointIndex_ = -1;
dragCurveZone_ = -1;
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
const bool deckTransient =
kind == DragKind::kDeckKnob &&
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
paramId == static_cast<int>(ParamControl::kMasterGain));
if (kind == DragKind::kScrollThumb || deckTransient) {
// Commit the voice count now that the drag is complete (one rebuild per full drag).
if (deckTransient && processor_ &&
paramId == static_cast<int>(ParamControl::kVoiceCount))
processor_->setVoiceCount(voiceCount_);
invalidate();
return;
}
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
// its amp keeps the last clamped drag value).
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
curveZone < static_cast<int>(map_.zones.size())) {
const bool off = x < curveRect.x - kCurveDragOffMargin ||
x > curveRect.right() + kCurveDragOffMargin ||
y < curveRect.y - kCurveDragOffMargin ||
y > curveRect.bottom() + kCurveDragOffMargin;
if (off) {
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
static_cast<std::size_t>(curveIdx));
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
}
}
commitAndReload();
}
void ReaSamplerEditor::onMouseRDown(int x, int y) {
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
// and drag-off remain as landed alternates. Commits immediately through the same path as
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
// Right-clicks act only while the popup is open — over the Sample face or the Zone
// surface (nothing else in the editor consumes them) — and never during an in-flight left
// drag.
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
if (drag_ != DragKind::kNone) return;
RECT rc{};
GetClientRect(childHwnd_, &rc);
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
if (!contains(pl.curveBox, x, y)) return;
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
if (idx < 0) return;
const int zi = popupZoneIndex();
if (zi < 0) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
selectedZone_ = zi;
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
commitAndReload();
}
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,124 @@
// editor_input_waveform.cpp — the WAVEFORM band's input: grabbing an envelope node or a
// start/loop marker, and resolving both drags live against the pure inverse maps
// (envelope_edit, waveform_view). Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
#include "shell/instrument/editor_internal.h"
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui;
using namespace reasampler::instrument::ui;
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
const Rect& band = fl.bands.waveform;
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
if (frames <= 0) return false;
// Envelope nodes first (they sit on top of the markers), then the wave markers.
const double rate = liveSampleRate();
if (rate > 0.0) {
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const double totalSeconds = static_cast<double>(frames) / rate;
const NodeHit nh = nodeAtPoint(env, band, totalSeconds, x, y);
if (nh.hit) {
drag_ = DragKind::kEnvNode;
envNode_ = nh.node;
dragStartX_ = x;
dragStartY_ = y;
dragStartEnv_ = env;
dragSampleFrames_ = frames;
dragStartFrame_ = startFrame;
dragStartParams_ = params_;
return true; // node moves once the cursor drags
}
}
const SetupMarkers m = pickedMarkers(frames);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(band, frames, markerFrames, 3, x, y);
if (hit >= 0) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartParams_ = params_;
return true;
}
return false;
}
void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
const Rect& band = fl.bands.waveform;
const int dx = x - dragStartX_;
if (drag_ == DragKind::kEnvNode) {
// Resolve the grabbed envelope node's new params from the pixel delta (through the
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto
// the parameter set. The AmpEnvelope was snapshotted at grab (dragStartEnv_) so the
// delta is absolute.
const std::int64_t frames = dragSampleFrames_;
const double rate = liveSampleRate();
if (frames <= 0 || rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, band, totalSeconds,
envClampBounds(), dx, y - dragStartY_);
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
invalidate(); // live feedback; commit on WM_LBUTTONUP
return;
}
// kWaveMarker: resolve the grabbed marker's new frame from the pixel delta,
// zero-crossing-snap it against the decoded PCM, apply the inter-marker clamps, and write
// the override live.
const std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
std::int64_t newFrame = resolveDragFrame(band, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
// frames — no host types, no file I/O.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
applyMarkers(m);
invalidate(); // live feedback; the commit lands on release
}
} // namespace reasampler::vst
#endif // _WIN32
+2 -2
View File
@@ -115,7 +115,7 @@ inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
instrument::ui::cardThumbnailRect(layout, 0))));
}
// Draws the title band with the live readout. Browse/Zone draw their own back button in
// Draws the title band with the live readout. The Browse modal draws its own back button in
// place of the nav.
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
const std::string& readout) {
@@ -172,7 +172,7 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
// a keyboard at a glance. Shared by the setup face + the Zones strip.
// a keyboard at a glance.
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
using instrument::ui::StripLayout;
if (stripArea.width <= 0 || stripArea.height <= 0) return;
+95
View File
@@ -0,0 +1,95 @@
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <string>
#include "shell/instrument/editor_internal.h" // kit adapters
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
// face draws first and the modal reads as a sheet layered on top.
paintSample(&bmp, w, h);
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
// A transient banner flashed after a file was dropped on this window. It reiterates the
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerTop = (std::min)(kTitleHeight, h);
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
const FaceLayout fl = faceLayout(w, h);
const bool empty = selectedId_.empty();
paintChrome(bmp, fl, empty);
// Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse
// (which the chrome lit above), and there is nothing to deck.
if (empty) {
Rect body = Rect::ltrb(fl.bands.waveform.x, fl.bands.waveform.y,
fl.bands.waveform.right(), fl.bands.decks.bottom());
paintEmptyState(bmp, body);
return;
}
paintWaveform(bmp, fl.bands.waveform);
paintDeck(bmp, fl);
// The curve popup: a centered sheet over the whole face, drawn last.
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
const char* msg = samples_.empty()
? "No captures in this project yet - capture audio into the bank to play it here."
: "No captures in this bank filter. Choose another bank tab above.";
// Split the area so the primary line sits centered and the ingest affordance sits just
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
// kept discoverable here regardless of whether a drop ever lands on this window.
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -1,9 +1,7 @@
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
// Shares the Sample face's painters (title band / empty state / deck / curve button /
// popup) via the class + editor_internal.h.
// editor_paint_browse.cpp — the Browse modal's painter: the full-window
// select-then-confirm picker (wash, search box, filter tabs, card grid, scrollbar, footer).
// Windows-only. Shares the Sample face's title-band + empty-state painters via the class +
// editor_internal.h.
#include "shell/instrument/reasampler_editor.h"
@@ -15,15 +13,14 @@
#include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
#include "shell/instrument/editor_internal.h" // kit adapters + labels
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
using namespace reasampler::instrument::ui; // browser geometry
using namespace reasampler::instrument::map; // SampleChoice / BankChoice
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
@@ -157,120 +154,6 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
}
}
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
// to the Sample home on close.
const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h));
drawTitleBand(bmp, title, "Zone - keyboard map");
{
const Rect back = zoneBackRect(w, h);
const KitButtonBox box{toKitBox(back)};
const InteractionState st =
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Back", st, /*warn=*/false);
}
const Rect content = zoneContentArea(w, h);
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
Rect addR = zoneAddRect(content);
{
const KitButtonBox box{toKitBox(addR)};
const InteractionState state =
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
}
Rect delR = zoneDeleteRect(addR);
if (selectedZone_ >= 0) {
const KitButtonBox box{toKitBox(delR)};
const InteractionState state =
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
// read-only here), so it is a normal button, not `warn`.
drawButton(bmp, box, "Delete", state, /*warn=*/false);
}
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
// zone is live"); the rest take the categorical secondary hue at low alpha.
const Rect stripArea = zonesStripArea(content);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
const int sx = stripArea.x;
const int sy = stripArea.y;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
const int bw = (std::max)(2, bar.width);
const bool sel = (i == selectedZone_);
if (sel) {
// Static glow halo behind the live zone, then the crisp accent-primary bar.
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
}
}
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
// text commits via parseNoteEntry on Enter.
const int legendTop = stripArea.bottom() + 8;
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
z.sampleId)
.c_str(),
Font::Label, Role::TextPrimary);
// Three fields laid out left-to-right after the sample label. A focused field lifts to
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
const Rect fields = noteEntryFieldsArea(content);
const char* names[3] = {"Low", "High", "Root"};
const std::string vals[3] = {
noteLabel(z.lowNote), noteLabel(z.highNote),
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
for (int f = 0; f < 3; ++f) {
const Rect fr = noteEntryFieldRect(fields, f);
const bool editing = (entryField_ == f);
fillSurface(bmp, toKitBox(fr), Role::BgCell,
editing ? InteractionState::Focus : InteractionState::Rest);
const KitColor border =
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
toLice(border), 1.0f, 0);
std::string cap = std::string(names[f]) + ": " +
(editing ? (entryText_ + "_") : vals[f]);
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
Font::ValueMono, Role::TextPrimary);
}
} else if (map_.zones.empty()) {
kitText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.",
Font::Label, Role::TextDim);
}
// The per-zone parameter surface for the selected zone: the same knob deck +
// curve-preview-button/popup grammar as the Sample face — one control language over the
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
// (ComponentState) and live on the Sample deck only.
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
paintCurveButton(bmp, zonesCurveButton(content), z);
}
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
// modal grammar as the Sample face.
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,118 @@
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
// live readout + Browse) and the control row (root/piano strip with its root marker, the
// preview trigger, the preview-velocity knob cell, the curve-preview button, and the
// Mono|Stereo toggle). Windows-only; all rects come from the pure sample_chrome interior.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <cstdio>
#include <string>
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face / spectral strip / root marker
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // chrome geometry + keyboard strip
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) {
const ChromeRects& cr = fl.chrome;
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
// channel-derived vstPluginName is the only beta-vs-stable signal.
std::string title = version::vstPluginName();
if (processor_ && processor_->bridge().isConnected()) {
// The instance's own loaded state outranks bank availability (the bank is a browser
// source, not the instrument's identity) — a self-contained instance names its sound
// (refs displayName fallback) even when the bank snapshot is empty.
if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else {
title += " [host: no bridge]";
}
drawTitleBand(bmp, cr.toolbar, title);
// Browse: the picker. When nothing is loaded it is the empty state's dominant
// call-to-action — draw it Active (accent-primary) so it reads as "start here".
{
const KitButtonBox box{toKitBox(cr.navBrowse)};
const InteractionState st = empty ? InteractionState::Active
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover
: InteractionState::Rest);
drawButton(bmp, box, "Browse", st, /*warn=*/false);
}
// The control row draws only once a capture is loaded — with nothing picked there is no
// root, no preview and no channel decision to make.
if (empty || cr.controls.empty()) return;
fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest);
// Root strip: the full 128-key spectral band with the root marked. The loaded capture
// responds across the whole strip, repitched from that root.
if (cr.rootStrip.width > 0) {
drawSpectralStrip(bmp, cr.rootStrip);
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
drawRootMarker(bmp, cr.rootStrip, sl, effectiveRoot());
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
{
const KitButtonBox box{toKitBox(cr.preview)};
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
: InteractionState::Rest);
drawButton(bmp, box, "Preview", st, /*warn=*/false);
}
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
{
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
const bool hov = isHovered(HoverKind::kVelKnob, -1);
const InteractionState st = dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover
: InteractionState::Rest);
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
if (dragging || hov) {
char buf[8];
snprintf(buf, sizeof(buf), "%d",
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
} else {
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
}
}
// The mini curve-preview button: opens the popup editor.
paintCurveButton(bmp, cr.curveBtn);
// Mono | Stereo output-mode toggle.
{
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
const InteractionState monoState = !isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
: InteractionState::Rest);
const InteractionState stereoState = isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState);
fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState);
kitTextCentered(bmp, cr.chanMono, "Mono", Font::Label,
!isStereo ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, cr.chanStereo, "Stereo", Font::Label,
isStereo ? Role::BgBase : Role::TextPrimary);
}
}
} // namespace reasampler::vst
#endif // _WIN32
+125
View File
@@ -0,0 +1,125 @@
// editor_paint_curve.cpp — the velocity->amp curve surfaces: the chrome band's mini
// preview button and the modal popup sheet that hosts the full editor. Band-independent
// (the popup floats over the whole face). Windows-only.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
#include "shell/instrument/editor_internal.h" // kit adapters + curveBoxFromRect
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // popup geometry
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r) {
if (r.width <= 0 || r.height <= 0) return;
// A hairline-bordered bg/cell square with the live velocity curve traced in miniature
// (no node markers at this scale). Hover lifts it; it draws Active (accent-primary
// border) while its popup is open, and re-renders live as the popup edits the curve.
const bool hov = isHovered(HoverKind::kCurveButton, -1);
fillSurface(bmp, toKitBox(r), Role::BgCell,
hov ? InteractionState::Hover : InteractionState::Rest);
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
: roleColor(Role::LineHairline);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
const VelocityCurve& curve = params_.velocityCurve;
const int inset = 3;
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
r.height - 2 * inset};
if (mini.width > 1 && mini.height > 1) {
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= mini.width; ++px) {
const int mx = mini.left + px;
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
prevX = mx;
prevY = my;
}
}
}
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
// face stays legible behind it), then the centered sheet.
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
const CurvePopupLayout pl = computeCurvePopup(w, h);
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
{
const KitButtonBox box{toKitBox(pl.close)};
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
? InteractionState::Hover
: InteractionState::Rest;
drawButton(bmp, box, "x", st, /*warn=*/false);
}
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
// trace/handles/drag-off cues cannot drift from the hit-test.
paintVelocityCurve(bmp, pl.curveBox);
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
// is the only host).
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = params_.velocityCurve;
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
// velocity and amp -> y mappings both go through the pure module so the trace, the node
// handles, and the hit-test all share one coordinate system.
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= box.width; ++px) {
const int cx = box.left + px;
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
// has passed kCurveDragOffMargin outside the box — release will delete the node).
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
dragCurveRect_.y == r.y) &&
(dragCurX_ < r.x - kCurveDragOffMargin ||
dragCurX_ > r.right() + kCurveDragOffMargin ||
dragCurY_ < r.y - kCurveDragOffMargin ||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
for (std::size_t i = 0; i < curve.points().size(); ++i) {
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
const bool grabbed = (drag_ == DragKind::kCurveNode &&
curvePointIndex_ == static_cast<int>(i));
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
// A grabbed node in drag-off territory draws warn to signal "release will delete."
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
: (hot ? handleHot : handle);
const int nr = 3;
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
}
}
} // namespace reasampler::vst
#endif // _WIN32
+139
View File
@@ -0,0 +1,139 @@
// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups (AMP
// ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER), their captions, the compact caption and
// row toggles, and the radial knobs with the label<->value swap on hover/drag.
// Windows-only; the deck's cell geometry is the pure knob_deck layout.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <string>
#include <vector>
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "shell/instrument/editor_internal.h" // kit adapters + knob face + DeckGroup ids
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // deck geometry
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
const Rect& deckArea = fl.bands.decks;
if (deckArea.width <= 0 || deckArea.height <= 0) return;
const DeckLayout dl = layoutDeck(fl.deckDescs, deckArea.x, deckArea.y, deckArea.width);
const PlaySeconds& play = params_.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
bool seg1Active, bool disabled) {
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
const InteractionState st0 =
disabled ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
const InteractionState st1 =
disabled ? InteractionState::Disabled
: (seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
disabled ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
disabled ? Role::TextDim
: (seg1Active ? Role::BgBase : Role::TextPrimary));
};
// The knob's short name label (swapped for the live value during hover/drag — no third
// line, no permanent value clutter).
const auto knobName = [](ParamControl c) -> const char* {
switch (c) {
case ParamControl::kAttack: return "Attack";
case ParamControl::kHold: return "Hold";
case ParamControl::kDecay: return "Decay";
case ParamControl::kSustain: return "Sustain";
case ParamControl::kRelease: return "Release";
case ParamControl::kTrigFadeIn: return "Fade In";
case ParamControl::kTrigLength: return "Len %";
case ParamControl::kTrigFadeOut: return "Fade Out";
case ParamControl::kKeyTrack: return "Key Trk";
case ParamControl::kPitchEnvAttack: return "P.Att";
case ParamControl::kPitchEnvDecay: return "P.Dec";
case ParamControl::kPitchEnvDepth: return "P.Depth";
case ParamControl::kVoiceCount: return "Voices";
case ParamControl::kMasterGain: return "Gain";
default: return "";
}
};
for (const DeckGroupLayout& g : dl.groups) {
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
hairline, 1.0f, 0);
const char* caption = "";
switch (g.id) {
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
case kGroupPitch: caption = "PITCH"; break;
case kGroupPitchEnv: caption = "PITCH ENV"; break;
case kGroupVoice: caption = "VOICE"; break;
case kGroupMaster: caption = "MASTER"; break;
default: break;
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The compact caption toggle (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
case ParamControl::kPlayMode:
drawToggle(g.captionToggle, "Gate", "Trigger",
play.playMode == PlayMode::Trigger, false);
break;
case ParamControl::kPitchEngine:
drawToggle(g.captionToggle, "Varisp", "Presrv",
play.pitchEngine == PitchEngine::Preserve, false);
break;
case ParamControl::kPitchEnvEnable:
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
break;
case ParamControl::kVoiceMode:
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
break;
default: break;
}
}
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
if (g.rowToggle.id >= 0) {
drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
// stable geometry.
for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
const InteractionState st =
disabled ? InteractionState::Disabled
: (dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest));
drawKnobFace(bmp, c.knob, deckControlNorm(c.id), st);
const std::string label = (dragging || hov)
? deckValueLabel(c.id)
: std::string(knobName(static_cast<ParamControl>(c.id)));
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
}
}
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -1,516 +0,0 @@
// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT
// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster
// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini
// preview button + popup sheet (shared painters the Zone surface reuses), and the empty
// state. Windows-only; draws through the shared kit by palette role. All layout math is
// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip)
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
using audio::computeEnvelope;
namespace {
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
// start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
// Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over
// Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
// modal reads as a sheet layered over the home face.
if (view_ == View::kZone) {
paintZone(&bmp, w, h);
} else {
paintSample(&bmp, w, h);
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
}
// A transient banner flashed after a file was dropped on this window. It reiterates the
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerTop = (std::min)(kTitleHeight, h);
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
// The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE
// group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
const PerformanceZone deckZone = effectiveSampleZone();
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
const SampleBands bands =
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
// Title: product name + live readout. The beta channel gets no distinct accent; the
// channel-derived vstPluginName is the only beta-vs-stable signal.
std::string title = version::vstPluginName();
if (processor_ && processor_->bridge().isConnected()) {
// The instance's own loaded state outranks bank availability (the bank is a browser
// source, not the instrument's identity) — a self-contained instance names its sound
// (refs displayName fallback) even when the bank snapshot is empty.
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else if (!selectedId_.empty())
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
else if (samples_.empty()) title += " [bank empty]";
else title += " [pick a capture]";
} else {
title += " [host: no bridge]";
}
drawTitleBand(bmp, bands.title, title);
// Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap
// surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw
// it Active (accent-primary) so it reads as "start here".
const bool empty = selectedId_.empty() && map_.zones.empty();
{
const KitButtonBox box{toKitBox(bands.navBrowse)};
const InteractionState st = empty ? InteractionState::Active
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest);
drawButton(bmp, box, "Browse", st, /*warn=*/false);
}
{
const KitButtonBox box{toKitBox(bands.navZone)};
const InteractionState st =
isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Zone", st, /*warn=*/false);
}
// Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing
// at Browse (which is lit above). No hero waveform / controls to draw.
if (empty) {
Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom());
paintEmptyState(bmp, body);
return;
}
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
// else the product-default play params (the single capture is a one-zone map). This is the
// one storage site both Sample and Zone edit.
const PerformanceZone& zone = deckZone;
// Hero waveform band: envelope + markers + envelope overlay.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = bands.hero;
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
if (frames > 0 && waveArea.width > 0) {
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
// partition — extra bins produce no visible change. Clamped to frame count below.
const std::int64_t wantBins =
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
kWaveformOversample;
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
drawEnvelope(bmp, waveArea, env);
const SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(waveArea, frames, m.loopStart);
const int rx = frameToX(waveArea, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height,
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
}
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
const int mx = frameToX(waveArea, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height,
toLice(roleColor(markerRoles[i])), alpha, 0);
}
// Trace the amp-envelope overlay + its draggable node handles over the hero.
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
} else {
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
}
// Root + preview cluster: remainder-width root strip, preview button, radial velocity
// knob, mini curve-preview button, channel toggle.
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
int root = effectiveRoot();
if (cr.rootStrip.width > 0) {
drawSpectralStrip(bmp, cr.rootStrip);
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
drawRootMarker(bmp, cr.rootStrip, sl, root);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
{
const KitButtonBox box{toKitBox(cr.preview)};
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
drawButton(bmp, box, "Preview", st, /*warn=*/false);
}
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
{
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
const bool hov = isHovered(HoverKind::kVelKnob, -1);
const InteractionState st = dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover
: InteractionState::Rest);
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
if (dragging || hov) {
char buf[8];
snprintf(buf, sizeof(buf), "%d",
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
} else {
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
}
}
// The mini curve-preview button: opens the popup editor. Shared painter with the Zone
// panel's button — one grammar on both surfaces.
paintCurveButton(bmp, cr.curveBtn, zone);
// Mono | Stereo output-mode toggle.
{
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
const InteractionState monoState = !isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest);
const InteractionState stereoState = isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
}
// The knob deck: the fenced control groups, bottom-anchored.
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
// The curve popup: a centered sheet over the whole Sample face, drawn last.
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
}
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
const PerformanceZone& zone, std::int64_t frames) {
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = zone.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
for (std::size_t i = 1; i < poly.size(); ++i) {
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
}
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
// the handle square is additionally clamped inside the hero rect so a 6px box on an edge
// node never overhangs into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
}
}
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
// is the only host).
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const VelocityCurve::Box box = curveBoxFromRect(r);
if (box.width <= 0 || box.height <= 1) return;
const VelocityCurve& curve = zone.velocityCurve;
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
// the hit-test all share one coordinate system.
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= box.width; ++px) {
const int cx = box.left + px;
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
prevX = cx;
prevY = cy;
}
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
// has passed kCurveDragOffMargin outside the box — release will delete the node).
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
dragCurveRect_.y == r.y) &&
(dragCurX_ < r.x - kCurveDragOffMargin ||
dragCurX_ > r.right() + kCurveDragOffMargin ||
dragCurY_ < r.y - kCurveDragOffMargin ||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
for (std::size_t i = 0; i < curve.points().size(); ++i) {
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
const bool grabbed = (drag_ == DragKind::kCurveNode &&
curvePointIndex_ == static_cast<int>(i));
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
// A grabbed node in drag-off territory draws warn to signal "release will delete."
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
: (hot ? handleHot : handle);
const int nr = 3;
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
}
}
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs) {
if (deckArea.width <= 0 || deckArea.height <= 0) return;
const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width);
const ZonePlaySeconds& play = zone.play;
const bool isMono = (voiceMode_ == VoiceMode::Mono);
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
bool seg1Active, bool disabled) {
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
const InteractionState st0 =
disabled ? InteractionState::Disabled
: (!seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
const InteractionState st1 =
disabled ? InteractionState::Disabled
: (seg1Active ? InteractionState::Active
: (hov ? InteractionState::Hover : InteractionState::Rest));
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
disabled ? Role::TextDim
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
disabled ? Role::TextDim
: (seg1Active ? Role::BgBase : Role::TextPrimary));
};
// The knob's short name label (swapped for the live value during hover/drag — no third
// line, no permanent value clutter).
const auto knobName = [](ParamControl c) -> const char* {
switch (c) {
case ParamControl::kAttack: return "Attack";
case ParamControl::kHold: return "Hold";
case ParamControl::kDecay: return "Decay";
case ParamControl::kSustain: return "Sustain";
case ParamControl::kRelease: return "Release";
case ParamControl::kTrigFadeIn: return "Fade In";
case ParamControl::kTrigLength: return "Len %";
case ParamControl::kTrigFadeOut: return "Fade Out";
case ParamControl::kKeyTrack: return "Key Trk";
case ParamControl::kPitchEnvAttack: return "P.Att";
case ParamControl::kPitchEnvDecay: return "P.Dec";
case ParamControl::kPitchEnvDepth: return "P.Depth";
case ParamControl::kVoiceCount: return "Voices";
case ParamControl::kMasterGain: return "Gain";
default: return "";
}
};
for (const DeckGroupLayout& g : dl.groups) {
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
hairline, 1.0f, 0);
const char* caption = "";
switch (g.id) {
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
case kGroupPitch: caption = "PITCH"; break;
case kGroupPitchEnv: caption = "PITCH ENV"; break;
case kGroupVoice: caption = "VOICE"; break;
case kGroupMaster: caption = "MASTER"; break;
default: break;
}
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
// The compact caption toggle (right-anchored in the caption row, never full-width).
if (g.captionToggle.id >= 0) {
switch (static_cast<ParamControl>(g.captionToggle.id)) {
case ParamControl::kPlayMode:
drawToggle(g.captionToggle, "Gate", "Trigger",
play.playMode == PlayMode::Trigger, false);
break;
case ParamControl::kPitchEngine:
drawToggle(g.captionToggle, "Varisp", "Presrv",
play.pitchEngine == PitchEngine::Preserve, false);
break;
case ParamControl::kPitchEnvEnable:
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
break;
case ParamControl::kVoiceMode:
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
break;
default: break;
}
}
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
if (g.rowToggle.id >= 0) {
drawToggle(g.rowToggle, "Retrig", "Legato",
monoTrigger_ == MonoTrigger::Legato, !isMono);
}
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
// stable geometry.
for (const DeckCellLayout& c : g.cells) {
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
const InteractionState st =
disabled ? InteractionState::Disabled
: (dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest));
drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st);
const std::string label = (dragging || hov)
? deckValueLabel(c.id, zone)
: std::string(knobName(static_cast<ParamControl>(c.id)));
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
}
}
}
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
const PerformanceZone& zone) {
if (r.width <= 0 || r.height <= 0) return;
// The mini curve-preview button (shared by the Sample cluster and the Zone panel): a
// hairline-bordered bg/cell square with the zone's live velocity curve traced in
// miniature (no node markers at this scale). Hover lifts it; it draws Active
// (accent-primary border) while its popup is open, and re-renders live as the popup edits
// the curve (same zone, re-read each paint).
const bool hov = isHovered(HoverKind::kCurveButton, -1);
fillSurface(bmp, toKitBox(r), Role::BgCell,
hov ? InteractionState::Hover : InteractionState::Rest);
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
: roleColor(Role::LineHairline);
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
const VelocityCurve& curve = zone.velocityCurve;
const int inset = 3;
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
r.height - 2 * inset};
if (mini.width > 1 && mini.height > 1) {
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
int prevX = 0, prevY = 0;
for (int px = 0; px <= mini.width; ++px) {
const int mx = mini.left + px;
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
prevX = mx;
prevY = my;
}
}
}
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
// Sample face stays legible behind it), then the centered sheet.
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
const CurvePopupLayout pl = computeCurvePopup(w, h);
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
{
const KitButtonBox box{toKitBox(pl.close)};
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
? InteractionState::Hover
: InteractionState::Rest;
drawButton(bmp, box, "x", st, /*warn=*/false);
}
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
// surface.
paintVelocityCurve(bmp, pl.curveBox, popupZone());
}
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
const char* msg = samples_.empty()
? "No captures in this project yet - capture audio into the bank to play it here."
: "No captures in this bank filter. Choose another bank tab above.";
// Split the area so the primary line sits centered and the ingest affordance sits just
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
// kept discoverable here regardless of whether a drop ever lands on this window.
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
} // namespace reasampler::vst
#endif // _WIN32
@@ -0,0 +1,124 @@
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop
// span + start/loop markers, and the amp-envelope overlay. Windows-only.
//
// Overlays that ride the waveform (the envelope trace, its node handles, the markers) draw
// ONCE across the full band height, never per lane — the landed contract the stacked-lane
// work consumes.
#include "shell/instrument/reasampler_editor.h"
#ifdef _WIN32
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
#include "shell/instrument/editor_internal.h" // kit adapters
#include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // lanes + waveform geometry
using audio::computeEnvelope;
namespace {
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
// start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
} // namespace
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest);
if (band.empty()) return;
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
if (frames <= 0) {
kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim);
return;
}
// One lane today: the cached PCM is a mono downmix, so there is no second channel to
// draw. The band is already sized for two, and the second lane lights up when the
// per-channel decode lands.
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/false);
const Rect& lane = lanes.upper;
if (lane.width > 0) {
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
// partition — extra bins produce no visible change. Clamped to frame count below.
const std::int64_t wantBins =
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(lane)))) *
kWaveformOversample;
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
drawEnvelope(bmp, lane, computeEnvelope(pcm, 1, pcm.size(), bins));
}
// Markers and the loop span run the FULL band height (both lanes), so a stacked view
// reads one loop region rather than two.
const SetupMarkers m = pickedMarkers(frames);
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(band, frames, m.loopStart);
const int rx = frameToX(band, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, band.y, rx - lx, band.height,
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
}
}
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
const int mx = frameToX(band, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, band.y, 2, band.height,
toLice(roleColor(markerRoles[i])), alpha, 0);
}
paintEnvelopeOverlay(bmp, band, frames);
}
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
std::int64_t frames) {
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
const double rate = liveSampleRate();
if (rate <= 0.0) return;
const double totalSeconds = static_cast<double>(frames) / rate;
const std::int64_t startFrame = params_.startPoint.value_or(0);
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
for (std::size_t i = 1; i < poly.size(); ++i) {
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
}
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
// the handle square is additionally clamped inside the band so a 6px box on an edge
// node never overhangs into the neighbouring bands.
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
for (const EnvVertex& v : poly) {
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
}
}
} // namespace reasampler::vst
#endif // _WIN32
+4 -6
View File
@@ -219,20 +219,18 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (no map mutation; dragStartMap_
// not snapshotted) — reset drag state only, never touch map_. Every
// map-editing drag rolls its live mutation back to the snapshot.
// voice count / master gain) are transient (they mutate no parameter, so
// dragStartParams_ is not a rollback target) — reset drag state only.
// Every parameter-editing drag rolls its live mutation back to the snapshot.
const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
if (!transient) self->map_ = self->dragStartMap_;
if (!transient) self->params_ = self->dragStartParams_;
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragParamZone_ = -1;
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
self->dragCurveZone_ = -1;
self->invalidate();
}
}
+39 -106
View File
@@ -1,8 +1,8 @@
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits
// off the audio thread via the processor's reloadInstrument.
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
// thread via the processor's reloadInstrument.
#include "shell/instrument/reasampler_editor.h"
@@ -38,8 +38,8 @@ using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size, tuned to the Sample-face band heights: title + hero waveform +
// cluster + control strip. 840x620 clears the full face without scroll on 1080p.
// Default view size, tuned to the three band heights: chrome + two-lane waveform +
// deck row. 840x620 clears the full face without scroll on 1080p.
ViewRect r(0, 0, 840, 620);
setRect(r);
}
@@ -53,30 +53,21 @@ void ReaSamplerEditor::refreshFromBank() {
banks_.clear();
visible_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
params_ = InstrumentParams{};
return;
}
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
const auto prevZoneCount = static_cast<int>(map_.zones.size());
map_ = processor_->performanceMap();
params_ = processor_->instrumentParams();
channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount();
voiceMode_ = processor_->voiceMode();
monoTrigger_ = processor_->monoTrigger();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
// modal would otherwise swallow clicks on the empty state.
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
// On the Zone surface, close the popup if the zone count changed at all — a mid-list
// deletion can leave selectedZone_ in range but silently naming a different zone.
if (view_ == View::kZone && curvePopupOpen_) {
const auto newZoneCount = static_cast<int>(map_.zones.size());
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
}
if (selectedId_.empty()) curvePopupOpen_ = false;
// Drop a filter that names a bank no longer present.
if (!activeFilterBankId_.empty()) {
bool found = false;
@@ -127,13 +118,13 @@ void ReaSamplerEditor::onSyncTimer() {
#endif // _WIN32
void ReaSamplerEditor::commitAndReload() {
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio
// thread. The reload also copies the picked capture's file ref + intrinsics into the
// instance-owned refs table — a browser load is the moment the instance becomes
// UI thread only. Publishes the edited selection + parameter set, then rebuilds off the
// audio thread. The reload also copies the loaded capture's file ref + intrinsics into
// the instance-owned refs table — a browser load is the moment the instance becomes
// self-contained for that sample.
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_);
processor_->setInstrumentParams(params_);
processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with.
@@ -144,23 +135,25 @@ void ReaSamplerEditor::commitAndReload() {
}
void ReaSamplerEditor::loadSelection(const std::string& id) {
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized
// full-range zone must not linger, or first-match resolve would keep playing it.
// Authored Zone-view maps (narrow key ranges) are left untouched.
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
// loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED
// overrides are: a root, a loop span and a start frame all name positions in the
// OUTGOING capture and mean nothing in the new one, so they clear and the new capture
// plays from its own bank intrinsics.
selectedId_ = id;
if (reconcileSingleCaptureZones(map_, selectedId_)) {
selectedZone_ = map_.zones.empty() ? -1 : 0;
}
params_.rootOverride.reset();
params_.loopOverride.reset();
params_.startPoint.reset();
commitAndReload();
}
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from
// the live bank blob (the same path selectSample uses); when that is not readable (extension
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override
// lives in map_.
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
// override win (the instrument's performance choice). Read the loop intrinsic from the
// live bank blob (the same path selectSample uses); when that is not readable (extension
// absent / not yet parsed) the instance-owned ref carries the same intrinsics.
if (processor_) {
std::optional<SelectedSample> sel;
auto banksJson =
@@ -176,17 +169,13 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
m.loopEnd = sel->loop.end;
}
}
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId != selectedId_) continue;
if (z.loopOverride) {
m.hasLoop = z.loopOverride->hasLoop;
m.loopStart = z.loopOverride->start;
m.loopEnd = z.loopOverride->end;
}
if (z.startPoint) m.start = *z.startPoint;
break;
// The parameter set's override (loop + start) supersedes the intrinsic.
if (params_.loopOverride) {
m.hasLoop = params_.loopOverride->hasLoop;
m.loopStart = params_.loopOverride->start;
m.loopEnd = params_.loopOverride->end;
}
if (params_.startPoint) m.start = *params_.startPoint;
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
// the markers are still drawn (drag one to CREATE a loop).
@@ -194,79 +183,23 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
return m;
}
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
// callers can update selectedZone_.
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
// Write the edited markers into the parameter set as the loop/start override. The bank
// intrinsic is never written (read-only bank consumer).
SampleLoop loop;
loop.hasLoop = m.hasLoop;
loop.start = m.loopStart;
loop.end = m.loopEnd;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.loopOverride = loop;
z.startPoint = m.start;
return i;
}
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.loopOverride = loop;
z.startPoint = m.start;
map_.zones.push_back(z);
return static_cast<int>(map_.zones.size()) - 1;
}
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
// The picked id's one-zone override, if the map already carries one; else a product-default
// zone bound to the picked id (not appended — a read-only resolve; a control edit
// materializes it via ensureSampleZone).
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) return z;
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
return z;
params_.loopOverride = loop;
params_.startPoint = m.start;
}
int ReaSamplerEditor::effectiveRoot() const {
int root = 60;
if (params_.rootOverride) return *params_.rootOverride;
for (const SampleChoice& s : samples_) {
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
if (s.id == selectedId_ && s.rootNote) return *s.rootNote;
}
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
}
return root;
}
int ReaSamplerEditor::ensureSampleZone() {
if (selectedId_.empty()) return -1;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
return static_cast<int>(map_.zones.size()) - 1;
}
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert):
// a full-keyboard zone carrying the override. This plays identically to the un-zoned single
// capture (one chromatic zone) and round-trips through the component state; the zone becomes
// visible if the user opens the Zones panel. The bank intrinsic is never written.
if (selectedId_.empty()) return;
upsertPickedOverride(m);
commitAndReload();
return 60;
}
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
+49 -83
View File
@@ -1,5 +1,5 @@
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the
// reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
// usage publish. Nothing here runs on the audio thread — process() only touches the
@@ -19,7 +19,7 @@
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained)
#include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
@@ -60,11 +60,10 @@ std::string mintUsageInstanceGuid() {
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the
// zoned build and the single-capture path.
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
const std::string& relativePath,
ChannelMode mode) {
// resolve/read/decode failure — the caller plays silence.
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
const std::string& relativePath,
ChannelMode mode) {
const std::string abs = resolveBankFile(projectDir, relativePath);
if (abs.empty()) return std::nullopt;
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
@@ -72,8 +71,8 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate));
DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate));
if (out.monoFrames.empty()) return std::nullopt;
return out;
}
@@ -95,8 +94,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// nothing below — a project restored before PROJEXTSTATE parses (or with the
// extension absent) resolves + plays from the persisted refs.
const std::string selId = selectedSampleId();
const PerformanceMap map = performanceMap();
const std::vector<std::string> ids = referencedSampleIds(selId, map);
const InstrumentParams params = instrumentParams();
const std::vector<std::string> ids = referencedSampleIds(selId);
SampleRefs refs;
{
std::optional<std::string> banksJson =
@@ -110,8 +109,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
}
const std::string projectDir = bridge_.activeProjectDir();
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch
// below may auto-default it before its decode.
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// capture's own channel count below, before the decode.
ChannelMode mode = channelMode();
// Snapshot the voice-system parameters once — baked into the built engine's
// construction (immutable config; a later change rebuilds).
@@ -127,60 +126,33 @@ std::string ReaSamplerProcessor::reloadInstrument() {
std::string resolvedId;
std::unique_ptr<LoadedInstrument> built;
Keymap km;
bool haveKeymap = false;
SampleData sample;
bool havePlayable = false;
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread,
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole
// map — the defined no-play, no crash, no retry loop.
if (!map.empty()) {
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
if (!resolved.zones.empty()) {
std::vector<DecodedZonePcm> decoded;
std::vector<ResolvedZone> kept;
decoded.reserve(resolved.zones.size());
kept.reserve(resolved.zones.size());
for (const ResolvedZone& rz : resolved.zones) {
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, rz.relativePath, mode);
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
kept.push_back(rz);
decoded.push_back(std::move(*pcm));
}
km = buildZonedKeymap(kept, decoded);
haveKeymap = !km.zones.empty();
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
// repitched from its effective root. No first-sample fallback: an empty selection
// (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
// rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
// no-play — no crash, no retry loop.
if (const SelectedSample* sel = findRef(refs, selId)) {
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
// count (always 2 for extension captures; mono only for ingest-imported mono files).
// An unknown count (0) or explicit user choice keeps the mode.
{
std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
havePlayable = sample.playable();
if (havePlayable) resolvedId = selId; // the concrete pick that resolved
}
}
// 3. Single-capture fast path: an empty performance map plays the one selected capture
// chromatically across the whole keyboard. No first-sample fallback: an empty
// selection (or one with no ref) resolves to nothing, so an un-picked instrument
// stays silent rather than auto-playing sample #1.
if (!haveKeymap) {
if (const SelectedSample* sel = findRef(refs, selId)) {
// Auto-default: channelModeFor computes the mode from the loaded capture's
// channel count (always 2 for extension captures; mono only for ingest-imported
// mono files). An unknown count (0) or explicit user choice keeps the mode.
{
std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_);
mode = channelMode_;
}
std::optional<DecodedZonePcm> pcm =
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
sel->rootNote, sel->loop,
std::move(pcm->framesR));
haveKeymap = true;
resolvedId = selId; // the concrete pick that resolved
}
}
}
if (haveKeymap) {
if (havePlayable) {
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
// valid window is always a real ring, covering a pathological host rate <= 0 too.
@@ -188,16 +160,16 @@ std::string ReaSamplerProcessor::reloadInstrument() {
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
}
// 4. Publish: atomically install the new instrument via the drain-slot swap (see the
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
// header). A null `built` (no ref / unreadable WAV) installs silence while any
// displaced tails still ring out via the drain.
publishBuiltLocked(std::move(built));
// 5. Publish this instance's held captures so the extension's prune can never reclaim
// 4. Publish this instance's held captures so the extension's prune can never reclaim
// them. Regardless of decode success: the holds are the refs the instance retains
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
publishUsage(refs, ids);
@@ -260,7 +232,7 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
void ReaSamplerProcessor::rebuildVoiceEngine() {
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
// the engine around a copy of the live instrument's already-decoded keymap — no
// the engine around a copy of the live instrument's already-decoded SampleData — no
// bridge, no disk — and publishes through the same drain-slot swap.
std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
@@ -282,12 +254,12 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from
// being freed.
Keymap km = cur->keymap;
// Deep-copy the decoded sample: safe to read concurrently with process() because the
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
// freed.
SampleData sample = cur->sample;
auto built = std::make_unique<LoadedInstrument>(
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
publishBuiltLocked(std::move(built));
}
@@ -322,7 +294,7 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
const LegacyLiftDecision decision = legacyLiftDecision(
bridge_.readReasamplerExtState(kProjExtBanksKey),
referencedSampleIds(selectedSampleId(), performanceMap()));
referencedSampleIds(selectedSampleId()));
if (decision == LegacyLiftDecision::Stale) {
// Provably stale: give up permanently. A later bank change that re-introduces an
// id bumps the generation, and genChanged refreshes the refs without this latch.
@@ -379,15 +351,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
if (decision.apply) {
// Apply as this instance's own selection (the instrument updates its own state,
// never the bank); reloadInstrument below rebuilds against it.
// never the bank); reloadInstrument below rebuilds against it. The parameter set
// carries over to the new capture — there is only one, and it governs whatever is
// loaded (the peer of the editor's Browse Load).
setSelectedSampleId(decision.sampleId);
// Peer of the editor's Browse Load: a stale full-range zone from the previous
// sample would shadow the assigned pick under first-match resolve. Authored maps
// (narrow key ranges) are untouched.
PerformanceMap reconciled = performanceMap();
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
setPerformanceMap(reconciled);
}
result.applied = true;
}
@@ -412,8 +379,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// dependency (a v10 blob plays from its refs with no poll at all).
bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) {
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
legacyLift = hasIntent && legacyLiftShouldRun();
legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
}
if (genChanged || result.applied || legacyLift) {
+18 -23
View File
@@ -1,6 +1,6 @@
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
// against the component_state_io codec) and its UI-thread parameter accessors/setters
// (selection, performance map, channel mode, preview velocity, voice-system params,
// (selection, the one parameter set, channel mode, preview velocity, voice-system params,
// master gain, preview-note mailbox posts). Everything here runs off the audio thread;
// setters hand work to the reload family (processor_reload.cpp) or store atomics
// process() picks up at block start.
@@ -15,7 +15,7 @@
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
#include "core/instrument/map/sample_map.h" // retainRefs / referencedSampleIds
using namespace Steinberg;
using namespace Steinberg::Vst;
@@ -34,18 +34,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
bytes.insert(bytes.end(), chunk, chunk + got);
}
// Component state is {single-capture selection id, opt-in zones}, restored explicitly
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_
// is the real host rate here — REAPER calls setupProcessing before setState on load.
// Component state is {loaded capture id, one parameter set}. deserializeComponentState
// lifts older blobs cleanly — including the retired zone payloads, which adopt zone
// one's capture into cs.selectionId. sampleRate_ is the real host rate here (REAPER
// calls setupProcessing before setState on load), which the legacy v3 payload's
// frames->seconds conversion needs.
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
setSelectedSampleId(cs.selectionId);
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones
// (one per sample ever browsed), the oldest shadowing the saved selection under
// first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched.
PerformanceMap restored = cs.map;
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally
setPerformanceMap(restored);
setInstrumentParams(cs.params);
// Restore the last-consumed assignment generation so a re-open does not re-apply a
// stale assign_request.
{
@@ -95,11 +91,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse;
// Persists the full instance state — never written to the "reasampler" bank ext-state.
// No pick + no zones serializes to {"", no zones}, restoring as silence (never
// auto-playing sample #1).
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
// sample #1).
ComponentState state_out;
state_out.selectionId = selectedSampleId();
state_out.map = performanceMap();
state_out.params = instrumentParams();
{
std::lock_guard<std::mutex> lock(channelModeMutex_);
state_out.channelMode = channelMode_;
@@ -121,8 +117,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
// present. Filtered (snapshot copy only) to what the instance currently plays, so the
// table cannot grow with browsing history.
state_out.sampleRefs = sampleRefs();
retainRefs(state_out.sampleRefs,
referencedSampleIds(state_out.selectionId, state_out.map));
retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId));
// Persist the publish identity so the usage key is stable across sessions.
{
std::lock_guard<std::mutex> lock(usageMutex_);
@@ -147,14 +142,14 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
selectedSampleId_ = id;
}
PerformanceMap ReaSamplerProcessor::performanceMap() {
std::lock_guard<std::mutex> lock(performanceMutex_);
return performanceMap_;
InstrumentParams ReaSamplerProcessor::instrumentParams() {
std::lock_guard<std::mutex> lock(paramsMutex_);
return params_;
}
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
std::lock_guard<std::mutex> lock(performanceMutex_);
performanceMap_ = map;
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
std::lock_guard<std::mutex> lock(paramsMutex_);
params_ = params;
}
SampleRefs ReaSamplerProcessor::sampleRefs() {
+133 -143
View File
@@ -1,8 +1,9 @@
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in
// the pure modules; every edit commits off the audio thread via reloadInstrument.
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
// pure modules; every edit commits off the audio thread via reloadInstrument.
#pragma once
@@ -16,9 +17,11 @@
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck)
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
#ifdef _WIN32
@@ -32,25 +35,26 @@ namespace reasampler::vst {
using audio::AudioSample;
using audio::Envelope;
using instrument::map::BankChoice;
using instrument::map::PerformanceMap;
using instrument::map::PerformanceZone;
using instrument::map::InstrumentParams;
using instrument::map::PlaySeconds;
using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs;
using instrument::map::ZonePlaySeconds;
using instrument::ui::AmpEnvelope;
using instrument::ui::ChromeRects;
using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds;
using instrument::ui::EnvNode;
using instrument::ui::Rect;
using instrument::ui::SampleBands;
class ReaSamplerProcessor;
class ReaSamplerEditor : public Steinberg::CPluginView {
public:
// `processor` outlives this editor; the editor reads the live bank through it and drives
// selection/zone edits + reload on user input. May be null (defensive; a real host always
// supplies one).
// selection/parameter edits + reload on user input. May be null (defensive; a real host
// always supplies one).
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
~ReaSamplerEditor() override;
@@ -65,18 +69,18 @@ protected:
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
private:
// Sample is the home/default face. Browse is a full-window modal picker overlaid on
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned.
enum class View { kSample, kBrowse, kZone };
// Sample is the home/default face (the three-band stack). Browse is a full-window modal
// picker overlaid on it.
enum class View { kSample, kBrowse };
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kCurveNode, kDeckKnob };
// Controls on the setup surface. The int value is the opaque control id the pure
// knob_deck hit-test returns; the shell maps it to the zone's play params or a
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
// processor-side per-instance setter.
enum class ParamControl {
kPlayMode = 0, // Gate | Trigger toggle
@@ -93,9 +97,9 @@ private:
kPitchEnvAttack, // AD pitch attack
kPitchEnvDecay, // AD pitch decay
kPitchEnvDepth, // AD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to
// the processor setters, never through applyZoneControl / the map.
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
// Deck-only controls: processor-side per-instance params — routed to the processor
// setters, never through applyParamControl.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
@@ -103,8 +107,8 @@ private:
kCount
};
// The waveform markers on the single-capture setup surface: start-point + the sustain
// loop's two ends, in draw + hit order.
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// in draw + hit order.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
@@ -112,9 +116,8 @@ private:
// not applicable.
enum class HoverKind {
kNone,
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
kNavZone, // the Sample-view "Zone" title-band button (opens the Zone surface)
kBack, // the Browse/Zone "back" affordance (returns to Sample)
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kBack, // the Browse "back" affordance (returns to Sample)
kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index)
@@ -122,13 +125,11 @@ private:
kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment
kPreview, // the Sample-view preview-trigger button
kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" zone button
kPreview, // the preview-trigger button
kControl, // a knob-deck element (index = control id)
kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the cluster preview-velocity radial knob
kCurveButton, // the cluster mini curve-preview button (opens the popup)
kVelKnob, // the chrome preview-velocity radial knob
kCurveButton, // the chrome mini curve-preview button (opens the popup)
kPopupClose, // the curve popup's Close (x) button
};
struct HoverTarget {
@@ -138,60 +139,82 @@ private:
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
};
// The three-band stack for the current client size, plus the chrome interior. Every
// paint/hit-test path derives both through this one call so draw and hit-test can never
// disagree about where a band is.
struct FaceLayout {
SampleBands bands;
ChromeRects chrome;
std::vector<DeckGroupDesc> deckDescs;
};
FaceLayout faceLayout(int w, int h) const;
#ifdef _WIN32
void paint(HDC hdc);
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
// The knob deck: group fence + caption + compact caption toggles + radial knobs with
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top).
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
const std::vector<DeckGroupDesc>& descs);
// The mini curve-preview button shared by the Sample cluster + the Zone panel.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site
// or the Zone surface's selected zone.
// --- Band painters (one TU each, mirroring the input side) ---
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
// curve button, channel toggle).
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
// Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
// Decks: the group fence + caption + compact caption toggles + radial knobs with
// label<->value swap on hover/drag.
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
// The mini curve-preview button (chrome) and the modal curve editor it summons.
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r);
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`.
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
std::int64_t frames);
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
// host is the popup sheet. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an
// empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers
// materialize first).
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
// Left-click while the curve popup is open (modal over both faces): Close /
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
// Returns true whenever the popup is open (it consumed the click).
bool handlePopupMouseDown(int w, int h, int x, int y);
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
// full band height (never per lane).
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, std::int64_t frames);
// --- Input: the mouse-down dispatch and its per-band branches ---
void onMouseDown(int x, int y);
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in
// editor_input_browse_zone.cpp).
// Each returns true when it consumed the click. Called in band order by onMouseDown.
bool mouseDownChrome(const FaceLayout& fl, int x, int y);
bool mouseDownWaveform(const FaceLayout& fl, int x, int y);
bool mouseDownDeck(const FaceLayout& fl, int x, int y);
void mouseDownBrowse(int w, int h, int x, int y);
void mouseDownZone(int w, int h, int x, int y);
// Live drag resolution, split on the same axis; each handles only its own DragKind
// values and is called from onMouseMove's router.
void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker
void dragWaveform(const FaceLayout& fl, int x, int y); // kEnvNode / kWaveMarker
void dragDeck(int x, int y); // kDeckKnob
void dragBrowse(int x, int y); // kScrollThumb
void dragCurve(int x, int y); // kCurveNode
void onMouseMove(int x, int y);
void onMouseUp(int x, int y);
// Right-click is the curve popup's primary node-delete affordance; only acts while the
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
void onMouseRDown(int x, int y);
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar
// (0..200% over the knob's 0..1).
void applyZoneControl(int zoneIndex, int id, double value, int segment);
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
// Alt-click on an interior node deletes it at once; an empty-space click adds a point
// and grabs it.
void handleCurveMouseDown(const Rect& r, int x, int y);
// Left-click while the curve popup is open (modal over the Sample face): Close /
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
// Returns true whenever the popup is open (it consumed the click).
bool handlePopupMouseDown(int w, int h, int x, int y);
// Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
// Repaints only on change, so an idle move is free. Windows-only.
// Repaints only on change, so an idle move is free. The per-band resolvers mirror the
// mouse-down branches but are read-only. Windows-only.
void resolveHover(int x, int y);
HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
HoverTarget hoverBrowse(int w, int h, int x, int y) const;
HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
bool isHovered(HoverKind kind, int index) const {
return hover_.kind == kind && hover_.index == index;
}
@@ -215,16 +238,15 @@ private:
#endif
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
// selection + parameter set. Main/UI thread only. Called on attach and after any edit.
void refreshFromBank();
// Publishes the edited zones/selection to the processor, then rebuilds the instrument
// off the audio thread. UI thread only.
// Publishes the edited selection + parameters to the processor, then rebuilds the
// instrument off the audio thread. UI thread only.
void commitAndReload();
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones
// first so the previous sample's materialized full-range zone cannot linger and shadow
// the new pick under first-match resolve, then publishes + reloads.
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
// whatever is loaded, so a load swaps the sound, not the settings.
void loadSelection(const std::string& id);
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
@@ -240,8 +262,8 @@ private:
// thread only (file I/O); cleared with the thumbnail cache on refresh.
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
// The effective loop + start markers for the picked capture: the per-zone override when
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop ->
// The effective loop + start markers for the loaded capture: the parameter set's
// override when one is set, else the bank's loop intrinsic / frame 0. Absent loop ->
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
struct SetupMarkers {
std::int64_t start = 0;
@@ -251,94 +273,74 @@ private:
};
SetupMarkers pickedMarkers(std::int64_t frames) const;
// Commits an edited marker set for the picked capture as a per-zone loop/start override
// (upsert on the picked id), then reloads off-thread.
void commitPickedMarkers(const SetupMarkers& m);
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
// callers decide live-drag vs final commit.
void applyMarkers(const SetupMarkers& m);
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_
// must be non-empty. Returns the updated/appended zone index.
int upsertPickedOverride(const SetupMarkers& m);
// Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch
// envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames.
// Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
// centered at 0.5).
double controlValue(int id, const ZonePlaySeconds& play) const;
double controlValue(int id, const PlaySeconds& play) const;
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
// toggle's `segment` (0/1). Mutates `play` in place.
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
void applyControl(int id, PlaySeconds& play, double value, int segment) const;
// Applies a knob/toggle interaction to the ONE parameter set for control `id`: ordinary
// controls route through applyControl; kKeyTrack writes the keyTrack scalar (0..200%
// over the knob's 0..1).
void applyParamControl(int id, double value, int segment);
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
// against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing.
// against the live rate — never a baked-in rate. Returns 0 when the rate is unknown.
double fadeMaxFrames() const;
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
// while the zone stores source frames — pack/unpack own that conversion (see
// while the parameter set stores source frames — pack/unpack own that conversion (see
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
// seconds are rate-free and copy 1-to-1.
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective
// start point (zone.startPoint.value_or(0)).
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const;
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place.
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
ZonePlaySeconds& play) const;
PlaySeconds& play) const;
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
// never produce a param a slider couldn't.
EnvClampBounds envClampBounds() const;
// The Sample face and the Zone surface read/write the same one-zone map site.
// effectiveSampleZone returns the picked id's override if present in map_, else a
// product-default zone (not yet materialized — a control edit does that).
PerformanceZone effectiveSampleZone() const;
// The effective root: rootOverride, else the bank intrinsic, else middle C.
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
int effectiveRoot() const;
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
double liveSampleRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const;
// Find-or-materializes the one-zone override for the picked id, appending a
// product-default zone if none exists. Mirror of upsertPickedOverride for a control
// edit. Returns -1 if selectedId_ is empty.
int ensureSampleZone();
// The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two
// reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH
// ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) /
// MASTER (Gain knob).
std::vector<DeckGroupDesc> deckGroupDescs(const PlaySeconds& play) const;
// The popup edits ONE zone per open: the Zone surface's selected zone or the Sample
// face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit
// target — materializes on the Sample face via ensureSampleZone, never on the Zone
// surface (button only shows for an explicit selection). -1 = no valid target.
PerformanceZone popupZone() const;
int popupZoneIndex();
// The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger
// Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows
// neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
// The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob +
// Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups.
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
// via the -2 sentinel) read the processor's live value.
double deckControlNorm(int id, const PerformanceZone& zone) const;
double deckControlNorm(int id) const;
// Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on
// release); processor params write through the processor setters immediately
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids.
void applyDeckKnob(int zoneIndex, int id, double norm);
// Applies a deck-knob value: parameter-set ids write params_ (commit on release);
// processor params write through the processor setters immediately (transient — no
// params edit, no reload).
void applyDeckKnob(int id, double norm);
// The knob's live value label shown during hover/drag: seconds, percents, source
// frames, signed semitones, a voice count, or the master-gain dB.
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
std::string deckValueLabel(int id) const;
ReaSamplerProcessor* processor_ = nullptr;
@@ -346,8 +348,8 @@ private:
std::vector<SampleChoice> samples_; // every bank sample, bank order
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
std::string selectedId_; // the single-capture pick ("" = empty state)
PerformanceMap map_; // the opt-in zones (empty = no zones)
std::string selectedId_; // the loaded capture ("" = empty state)
InstrumentParams params_; // the ONE parameter set governing it
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
@@ -357,10 +359,9 @@ private:
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
// Transient UI state (not persisted; component state carries selection + zones).
// Transient UI state (not persisted; component state carries selection + parameters).
View view_ = View::kSample; // default face is the loaded-sample home
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
// Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
@@ -381,11 +382,6 @@ private:
std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus
// When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_
// accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing.
int entryField_ = -1;
std::string entryText_;
// Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32
@@ -398,10 +394,8 @@ private:
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
int dragCurX_ = 0; // live cursor x (px) during a drag — updated in onMouseMove
int dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove
int dragStartLow_ = 0; // the grabbed field's note at grab time
int dragStartHigh_ = 0;
int dragStartRoot_ = 60;
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
int dragStartRoot_ = 60; // the root note at grab time
InstrumentParams dragStartParams_; // params_ snapshotted at grab; restored on capture-loss
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
// pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
@@ -409,12 +403,11 @@ private:
WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
std::int64_t dragStartFrame_ = 0; // effective start point at grab time; for env-node drag
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone.
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
int dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
// contract, per envelope_edit's grabEnv).
@@ -422,19 +415,16 @@ private:
AmpEnvelope dragStartEnv_{};
// Velocity-curve node drag: which point, the curve snapshotted at grab
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone
// place the editor differently), and which zone the edit lands on.
// (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
int curvePointIndex_ = -1;
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
Rect dragCurveRect_{};
int dragCurveZone_ = -1;
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
// delta from this anchor, so a grab never jumps the value.
double dragKnobStartValue_ = 0.0;
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
// Curve popup open flag, never persisted.
bool curvePopupOpen_ = false;
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
+45 -65
View File
@@ -4,6 +4,7 @@
#include "shell/instrument/reasampler_embed.h"
#include <algorithm>
#include <string>
#include <vector>
@@ -62,15 +63,6 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
}
#endif
// Projects the performance map into the strip's minimal zone shape (key ranges only).
// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it.
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
std::vector<EmbedZone> out;
out.reserve(map.zones.size());
for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote});
return out;
}
} // namespace
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
@@ -80,26 +72,40 @@ tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
return kNoInterface;
}
// The loaded capture id + effective root, both cheap in-process accessors.
void ReaSamplerEmbed::refreshLoaded() {
loadedId_ = processor_->selectedSampleId();
const InstrumentParams params = processor_->instrumentParams();
if (params.rootOverride) {
rootNote_ = *params.rootOverride;
} else {
const SampleRefs refs = processor_->sampleRefs();
if (const SelectedSample* ref = findRef(refs, loadedId_)) {
rootNote_ = ref->rootNote;
} else {
rootNote_ = 60;
}
}
}
void ReaSamplerEmbed::refresh() {
if (!processor_) {
samples_.clear();
map_.zones.clear();
selectedZone_ = -1;
loadedId_.clear();
rootNote_ = 60;
return;
}
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
map_ = processor_->performanceMap();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
refreshLoaded();
}
void ReaSamplerEmbed::maybeRefresh() {
if (!processor_) { refresh(); return; } // clears state; cheap
// The performance map is a cheap in-process accessor, and the editor may edit zones
// with no bank-content change — always re-snapshot it so an edit reflects immediately.
map_ = processor_->performanceMap();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// The loaded capture + root are cheap in-process accessors, and the editor may change
// either with no bank-content change — always re-snapshot so an edit reflects at once.
refreshLoaded();
// The expensive part is the bank-blob bridge read: gate it on the bank-generation
// stamp, re-reading only when it changed (or on the first paint). A project with no
@@ -157,9 +163,6 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
#ifdef _WIN32
case REAPER_FXEMBED_WM_PAINT:
return paint(parm2, parm3) ? 1 : 0;
case REAPER_FXEMBED_WM_LBUTTONDOWN:
// Selection at most: map the click to a zone; force a redraw if it changed.
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
#endif
default:
return 0; // unhandled messages (cursor, wheel, hittest) fall through
@@ -186,45 +189,35 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
const EmbedLayout layout = layoutEmbed(w, h);
if (map_.zones.empty()) {
// No opt-in zones authored: a faint band so the strip reads as "present, no zones"
// — the default single-capture face lives in the editor.
if (loadedId_.empty()) {
// Nothing loaded: a faint band so the strip reads as "present, silent" — the pick
// affordance lives in the editor.
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
const std::string label = version::vstPluginName() + // channel-derived
(samples_.empty() ? " (bank empty)" : " (no zones)");
(samples_.empty() ? " (bank empty)" : " (pick a capture)");
const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
layout.keymap.bottom());
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
} else {
// Each zone draws as a segment (first-match order, matching selection/playback),
// colored by its key span's spectral hue so it reads as the same spectrum as the
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow.
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[i];
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
if (r.width <= 0) continue;
const bool sel = (i == selectedZone_);
if (sel) {
// Static glow halo, then the crisp accent-primary fill.
LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height,
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0;
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
toLice(spectralColor(t)), 0.65f, 0);
}
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
// Label when wide enough to read; the selected (accent-fill) segment labels in
// bg/base for contrast, the rest in text/primary.
if (r.width >= 24) {
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom());
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left);
}
// The loaded capture spans the whole keyboard, drawn in its root's spectral hue so
// the strip reads as the same spectrum as the editor's keyboard strip; the root key
// lifts to accent-primary with a static glow ("this is where it plays at unity").
const Rect span = keySpanRect(layout, 0, 127);
LICE_FillRect(bmp, span.x, span.y, span.width, span.height,
toLice(spectralColor(rootNote_ / 127.0)), 0.65f, 0);
LICE_DrawRect(bmp, span.x, span.y, span.width - 1, span.height - 1,
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
const Rect root = keySpanRect(layout, rootNote_, rootNote_);
const int rw = (std::max)(2, root.width);
LICE_FillRect(bmp, root.x - 2, root.y, rw + 4, root.height,
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, root.x, root.y, rw, root.height,
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
if (span.width >= 24) {
const Rect lr = Rect::ltrb(span.x + 3, span.y, span.right() - 2, span.bottom());
text(bmp, toKitBox(lr), sampleLabel(samples_, loadedId_).c_str(), Font::Label,
Role::TextPrimary, Align::Left);
}
}
@@ -243,19 +236,6 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
return true;
}
bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) {
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
if (!di || di->width <= 0 || di->height <= 0) return false;
refresh();
const EmbedLayout layout = layoutEmbed(di->width, di->height);
const std::vector<EmbedZone> zones = toEmbedZones(map_);
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(zones.size()),
di->mouse_x, di->mouse_y);
if (hit == selectedZone_) return false; // no change -> no redraw
selectedZone_ = hit;
return true;
}
#endif // _WIN32
} // namespace reasampler::vst
+14 -14
View File
@@ -2,8 +2,9 @@
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in
// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing
// here runs in process(). Windows-only, guarded so a non-Windows build stays compilable.
// The strip's layout + hit-test is pure (embed_strip.h, unit-tested); this shell marshals
// REAPER's messages to/from it.
// The strip's layout is pure (embed_strip.h, unit-tested); this shell marshals REAPER's
// messages to/from it. The strip is a read-only readout — the loaded capture across the
// keyboard with its root marked, plus the activity level.
#pragma once
@@ -13,7 +14,7 @@
#include "pluginterfaces/base/funknown.h"
#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects)
#include "core/instrument/map/sample_map.h" // SampleChoice (the state the strip reflects)
// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is
// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
@@ -26,7 +27,6 @@ namespace reasampler::vst {
class ReaSamplerProcessor;
using instrument::map::PerformanceMap;
using instrument::map::SampleChoice;
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
@@ -59,17 +59,19 @@ private:
#ifdef _WIN32
// Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
// A mouse-down inside the strip: maps to a zone and selects it (no new editing
// semantics). Returns true if the selection changed (caller then invalidates).
bool onMouseDown(Steinberg::TPtrInt drawInfo);
#endif
// Snapshots the live bank + the instrument's performance map for the next paint.
// The loaded capture id + effective root (cheap in-process accessors, no bridge read).
// `processor_` must be non-null.
void refreshLoaded();
// Snapshots the live bank + the instrument's loaded capture and effective root for the
// next paint.
void refresh();
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation
// stamp changed since the last paint. The performance map is always refreshed (cheap
// in-process accessor) so a zone edit reflects immediately. UI thread only.
// stamp changed since the last paint. The loaded capture + root are always refreshed
// (cheap in-process accessors) so an edit reflects immediately. UI thread only.
void maybeRefresh();
ReaSamplerProcessor* processor_ = nullptr;
@@ -77,10 +79,8 @@ private:
// from a real generation 0, forcing the first maybeRefresh() to do a full read.
std::int64_t lastSeenBankGeneration_ = -1;
std::vector<SampleChoice> samples_;
PerformanceMap map_;
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's
// highlight.
int selectedZone_ = -1;
std::string loadedId_; // the loaded capture's bank id; "" = nothing loaded
int rootNote_ = 60; // effective root: override, else the capture's own intrinsic
};
} // namespace reasampler::vst
+27 -27
View File
@@ -17,37 +17,37 @@
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split)
#include "core/instrument/engine/sampler_core.h"
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst {
using instrument::map::ComponentState;
using instrument::map::PerformanceMap;
using instrument::map::InstrumentParams;
using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
// Decoded keymap + the voice engine playing it. The engine holds references into the
// keymap, so both must live/die together at a stable address — heap-allocated,
// The decoded capture + the voice engine playing it. The engine holds a reference to the
// sample, so both must live/die together at a stable address — heap-allocated,
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
struct LoadedInstrument {
Keymap keymap;
SampleData sample;
VoiceEngine engine;
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
// Takeover declick is on by default here (product default; the pure core defaults it
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
// of clicking.
LoadedInstrument(Keymap km, std::size_t maxVoices,
LoadedInstrument(SampleData sd, std::size_t maxVoices,
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
: keymap(std::move(km)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
: sample(std::move(sd)),
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
installedAt(gen) {}
@@ -111,7 +111,7 @@ public:
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
}
// Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
// off-thread, and publishes the built instrument via atomic swap — no bank read
// required. When the bank blob is readable it's first folded into the refs table
// (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
@@ -140,16 +140,16 @@ public:
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
// shares this time base. 0.0 before setupProcessing runs.
double sampleRate() const { return sampleRate_; }
// The single-capture selection id (guarded by selectionMutex_, never read on the audio
// thread): the default face's pick when the performance map is empty; a non-empty map
// supersedes it. Empty id -> silence, no first-sample fallback.
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// Empty id -> silence, no first-sample fallback.
std::string selectedSampleId();
void setSelectedSampleId(const std::string& id);
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread.
PerformanceMap performanceMap();
void setPerformanceMap(const PerformanceMap& map);
// The one parameter set governing that capture. UI thread, guarded by paramsMutex_;
// never read on the audio thread — reloadInstrument bakes it into the SampleData
// off-thread.
InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
@@ -165,8 +165,8 @@ public:
void setPreviewVelocity(std::uint8_t velocity);
// Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no
// bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded SampleData,
// no bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
int voiceCount();
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode();
@@ -203,7 +203,7 @@ private:
void retireIdleDrain();
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
// already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot
// already-decoded SampleData (no bridge/disk) and publishes through the same drain-slot
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
void rebuildVoiceEngine();
@@ -253,15 +253,15 @@ private:
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read
// on the audio thread.
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// audio thread.
std::mutex selectionMutex_;
std::string selectedSampleId_;
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into
// the Keymap under the reload lock, never read directly on the audio thread.
std::mutex performanceMutex_;
PerformanceMap performanceMap_;
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
// under the reload lock, never read directly on the audio thread.
std::mutex paramsMutex_;
InstrumentParams params_;
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
// opportunistically from the bank blob when readable; never a bank dependency for
+3
View File
@@ -12,6 +12,9 @@
// and returning every index for an empty query.
#include "../src/core/instrument/ui/browser_scroll.h"
// The modal reuses the Sample face's chrome metrics (kPad / kTitleHeight); assert against
// those same constants so a metric change can never desync the sheet from what it covers.
#include "../src/core/instrument/ui/sample_bands.h"
#include <cstdio>
#include <string>
+753 -149
View File
@@ -1,14 +1,16 @@
// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite the full
// envelope/payload version ladder, every legacy lift, the golden byte fixtures —
// lives in test_sample_map.cpp and runs unmodified against the split module; this
// target exists as the module's OWN executable (house rule: every pure module has
// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine
// (T2-07): it links component_state_io + velocity_curve + master_gain only — a
// sampler_core/pitch_shift symbol reaching this link is a regression.
// component_state_io unit tests — the codec's whole suite: the current envelope + params
// payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
// version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
// -> the one parameter set, adopting zone one). The codec's own executable is also the
// STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
// velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
// link is a regression.
#include "../src/core/instrument/map/component_state_io.h"
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -25,7 +27,190 @@ static int failures = 0;
} \
} while (0)
// A full round-trip through the CURRENT envelope (v11): every field survives.
// --- A writer for the RETIRED zone-list payloads -----------------------------
//
// The shipping codec no longer EMITS a zone list, so the migration ladder can only be
// tested against bytes this suite lays out itself. These helpers mirror the frozen v1..v7
// record shapes documented in component_state_io.h; if they and the reader ever disagree,
// the migration tests below fail — which is the point.
namespace legacy {
static void u8v(std::vector<std::uint8_t>& out, std::uint8_t v) { out.push_back(v); }
static void u32v(std::vector<std::uint8_t>& out, std::uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
}
static void i64v(std::vector<std::uint8_t>& out, std::int64_t v) {
const auto u = static_cast<std::uint64_t>(v);
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((u >> (8 * i)) & 0xFF));
}
static void f64v(std::vector<std::uint8_t>& out, double v) {
std::uint64_t bits = 0;
std::memcpy(&bits, &v, sizeof(bits));
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((bits >> (8 * i)) & 0xFF));
}
static void strv(std::vector<std::uint8_t>& out, const std::string& s) {
u32v(out, static_cast<std::uint32_t>(s.size()));
out.insert(out.end(), s.begin(), s.end());
}
// One zone's worth of the retired per-zone record, in the v7 (fullest) shape.
struct Zone {
std::string sampleId;
int lowNote = 0;
int highNote = 127;
int rootOverride = -1; // < 0 = absent
bool hasLoopOverride = false;
// The override's OWN hasLoop bit — distinct from hasLoopOverride above. An override can
// itself say "disable the loop" (loopOverrideHasLoop = false): the field is present but
// sets no sustain loop, as opposed to no override at all (the sample's own intrinsic loop
// applies). Defaults true so existing callers that only set hasLoopOverride keep writing
// the enabled-loop shape they always did.
bool loopOverrideHasLoop = true;
std::int64_t loopStart = 0;
std::int64_t loopEnd = 0;
std::int64_t startPoint = -1; // < 0 = absent
bool trigger = false;
double holdSeconds = 0.0;
double lengthFraction = 1.0;
std::int64_t fadeIn = 0;
std::int64_t fadeOut = 0;
bool preserve = false;
bool pitchEnvEnabled = false;
double pitchAttack = 0.0;
double pitchDecay = 0.0;
double peakSemis = 0.0;
double attackSeconds = 0.003;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
double keyTrack = 1.0;
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
};
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
strv(out, z.sampleId);
u32v(out, static_cast<std::uint32_t>(z.lowNote));
u32v(out, static_cast<std::uint32_t>(z.highNote));
u8v(out, z.rootOverride >= 0 ? 1 : 0);
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
if (pv >= 2) {
u8v(out, z.hasLoopOverride ? 1 : 0);
if (z.hasLoopOverride) {
u8v(out, z.loopOverrideHasLoop ? 1 : 0);
i64v(out, z.loopStart);
i64v(out, z.loopEnd);
}
u8v(out, z.startPoint >= 0 ? 1 : 0);
if (z.startPoint >= 0) i64v(out, z.startPoint);
}
if (pv >= 5) {
u8v(out, z.trigger ? 1 : 0);
f64v(out, z.holdSeconds);
f64v(out, z.lengthFraction);
i64v(out, z.fadeIn);
i64v(out, z.fadeOut);
u8v(out, z.preserve ? 1 : 0);
u8v(out, z.pitchEnvEnabled ? 1 : 0);
f64v(out, z.pitchAttack);
f64v(out, z.pitchDecay);
f64v(out, z.peakSemis);
f64v(out, z.attackSeconds);
f64v(out, z.decaySeconds);
f64v(out, z.sustainLevel);
f64v(out, z.releaseSeconds);
}
if (pv >= 6) f64v(out, z.keyTrack);
if (pv >= 7) {
const std::vector<VelocityPoint> pts =
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
u32v(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); }
}
}
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
// version N carried, so each lift can be asserted against a blob shaped exactly as that
// version's writer produced.
struct Envelope {
std::uint32_t version = kComponentStateVersion;
std::uint8_t modeByte = 0; // v4+ 0 mono / 1 stereo
std::int64_t assignGeneration = 0; // v5+
std::uint8_t previewVelocity = kPreviewVelocityDefault; // v6+
std::uint8_t voiceCount = static_cast<std::uint8_t>(kDefaultVoiceCount); // v7+
std::uint8_t voiceMode = 0; // v7+ 0 poly / 1 mono
std::uint8_t monoTrigger = 0; // v7+ 0 retrigger / 1 legato
double masterGain = 1.0; // v8+
std::uint8_t channelModeExplicit = 0; // v9+
std::string instanceGuid; // v11+
std::string selectionId; // v3+
};
// A complete envelope at `env.version` whose tail is a RETIRED zone-list payload at version
// `pv`. `env.selectionId` is the envelope's own stored pick — which the adoption rule
// overrides when the payload carries a zone. The sample-refs table (v10+) is always empty:
// its own shape is covered by the round-trip test.
static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
const std::vector<Zone>& zones,
std::uint32_t pv) {
std::vector<std::uint8_t> out;
const std::uint32_t v = env.version;
u32v(out, v);
if (v >= 4) u8v(out, env.modeByte);
if (v >= 5) i64v(out, env.assignGeneration);
if (v >= 6) u8v(out, env.previewVelocity);
if (v >= 7) { u8v(out, env.voiceCount); u8v(out, env.voiceMode); u8v(out, env.monoTrigger); }
if (v >= 8) f64v(out, env.masterGain);
if (v >= 9) u8v(out, env.channelModeExplicit);
if (v >= 10) u32v(out, 0); // sample-refs: empty table
if (v >= 11) strv(out, env.instanceGuid);
if (v >= 3) strv(out, env.selectionId); // v2 was zones-only, no selection
if (pv >= 2) {
u32v(out, kParamsFormatMarker);
u32v(out, pv);
}
u32v(out, static_cast<std::uint32_t>(zones.size()));
for (const Zone& z : zones) putZone(out, z, pv);
return out;
}
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
const std::vector<Zone>& zones,
std::uint32_t pv) {
Envelope env;
env.selectionId = selectionId;
return envelopeWithZones(env, zones, pv);
}
} // namespace legacy
// --- The current format -------------------------------------------------------
// Builds a SampleRefEntry with the intrinsics fields the refs-robustness tests below need to
// set individually (root/loop/channels), mirroring the codec's own field names.
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
bool hasLoop = false, std::int64_t loopStart = 0,
std::int64_t loopEnd = 0, int channels = 0,
const std::string& name = "") {
SampleRefEntry e;
e.sampleId = id;
e.ref.relativePath = rel;
e.ref.rootNote = root;
e.ref.loop.hasLoop = hasLoop;
e.ref.loop.start = loopStart;
e.ref.loop.end = loopEnd;
e.ref.channelCount = channels;
e.displayName = name;
return e;
}
// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
// survives. This is the "one parameter set round-trips save/reload intact" contract.
static void testComponentStateRoundTrip() {
ComponentState in;
in.selectionId = "smp-1";
@@ -48,18 +233,30 @@ static void testComponentStateRoundTrip() {
e.ref.channelCount = 2;
e.displayName = "My Capture";
in.sampleRefs.push_back(e);
PerformanceZone z;
z.sampleId = "smp-1";
z.lowNote = 30;
z.highNote = 90;
z.rootOverride = 61;
z.startPoint = 5;
z.keyTrack = 1.5;
z.play.playMode = PlayMode::Trigger;
z.play.trigger.lengthFraction = 0.75;
z.play.trigger.fadeInFrames = 441;
z.play.trigger.fadeOutFrames = 882;
in.map.zones.push_back(z);
in.params.rootOverride = 61;
SampleLoop lp;
lp.hasLoop = true;
lp.start = 7;
lp.end = 900;
in.params.loopOverride = lp;
in.params.startPoint = 5;
in.params.keyTrack = 1.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
in.params.play.playMode = PlayMode::Trigger;
in.params.play.adsr.attackSeconds = 0.01;
in.params.play.adsr.holdSeconds = 0.05;
in.params.play.adsr.decaySeconds = 0.02;
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 441;
in.params.play.trigger.fadeOutFrames = 882;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
const ComponentState out = deserializeComponentState(bytes, 48000.0);
@@ -85,32 +282,38 @@ static void testComponentStateRoundTrip() {
CHECK(out.sampleRefs[0].ref.channelCount == 2);
CHECK(out.sampleRefs[0].displayName == "My Capture");
}
CHECK(out.map.zones.size() == 1);
if (out.map.zones.size() == 1) {
const PerformanceZone& oz = out.map.zones[0];
CHECK(oz.sampleId == "smp-1");
CHECK(oz.lowNote == 30);
CHECK(oz.highNote == 90);
CHECK(oz.rootOverride && *oz.rootOverride == 61);
CHECK(oz.startPoint && *oz.startPoint == 5);
CHECK(oz.keyTrack == 1.5);
CHECK(oz.play.playMode == PlayMode::Trigger);
CHECK(oz.play.trigger.lengthFraction == 0.75);
CHECK(oz.play.trigger.fadeInFrames == 441);
CHECK(oz.play.trigger.fadeOutFrames == 882);
}
const InstrumentParams& p = out.params;
CHECK(p.rootOverride && *p.rootOverride == 61);
CHECK(p.loopOverride && p.loopOverride->hasLoop);
CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
CHECK(p.startPoint && *p.startPoint == 5);
CHECK(p.keyTrack == 1.5);
CHECK(p.velocityCurve.points().size() == 3);
CHECK(p.play.playMode == PlayMode::Trigger);
CHECK(p.play.adsr.attackSeconds == 0.01);
CHECK(p.play.adsr.holdSeconds == 0.05);
CHECK(p.play.adsr.decaySeconds == 0.02);
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 441);
CHECK(p.play.trigger.fadeOutFrames == 882);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). testEnvelopePrefixBytesFrozen below
// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the
// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would
// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field
// family at once (two zones — one Trigger with every optional override set, one Gate with all
// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode
// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected
// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the
// round-trip test above) captured as the golden — so the byte layout itself becomes
// un-driftable, not just its first 5 bytes.
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
// pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
// byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
// pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
// field family at once (a two-entry sample-refs table — one with a loop, one without — every
// optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
// and asserts the encoded bytes equal an EXACT expected vector, captured from the current
// writer's output and checked field-for-field against the v8/v11 layout documented in
// component_state_io.h.
static void testGoldenFullBlobFixture() {
ComponentState in;
in.selectionId = "kick";
@@ -146,51 +349,30 @@ static void testGoldenFullBlobFixture() {
snareRef.displayName = "Snare";
in.sampleRefs.push_back(snareRef);
// Zone A: every optional field present, Trigger mode, non-flat velocity curve.
PerformanceZone zoneA;
zoneA.sampleId = "kick";
zoneA.lowNote = 24;
zoneA.highNote = 60;
zoneA.rootOverride = 36;
in.params.rootOverride = 36;
SampleLoop loopA;
loopA.hasLoop = true;
loopA.start = 1000;
loopA.end = 5000;
zoneA.loopOverride = loopA;
zoneA.startPoint = 250;
zoneA.keyTrack = 0.5;
zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
in.params.loopOverride = loopA;
in.params.startPoint = 250;
in.params.keyTrack = 0.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
zoneA.play.playMode = PlayMode::Trigger;
zoneA.play.adsr.attackSeconds = 0.01;
zoneA.play.adsr.holdSeconds = 0.05;
zoneA.play.adsr.decaySeconds = 0.02;
zoneA.play.adsr.sustainLevel = 0.8;
zoneA.play.adsr.releaseSeconds = 0.15;
zoneA.play.trigger.lengthFraction = 0.75;
zoneA.play.trigger.fadeInFrames = 100;
zoneA.play.trigger.fadeOutFrames = 200;
zoneA.play.pitchEngine = PitchEngine::Preserve;
zoneA.play.pitchEnv.enabled = true;
zoneA.play.pitchEnv.attackSeconds = 0.02;
zoneA.play.pitchEnv.decaySeconds = 0.03;
zoneA.play.pitchEnv.peakSemitones = 5.0;
in.map.zones.push_back(zoneA);
// Zone B: every optional field absent, Gate mode, default flat velocity curve.
PerformanceZone zoneB;
zoneB.sampleId = "snare";
zoneB.lowNote = 61;
zoneB.highNote = 90;
zoneB.keyTrack = 2.0;
zoneB.play.playMode = PlayMode::Gate;
zoneB.play.adsr.attackSeconds = 0.005;
zoneB.play.adsr.holdSeconds = 0.0;
zoneB.play.adsr.decaySeconds = 0.1;
zoneB.play.adsr.sustainLevel = 0.5;
zoneB.play.adsr.releaseSeconds = 0.2;
zoneB.play.pitchEngine = PitchEngine::Varispeed;
in.map.zones.push_back(zoneB);
in.params.play.playMode = PlayMode::Trigger;
in.params.play.adsr.attackSeconds = 0.01;
in.params.play.adsr.holdSeconds = 0.05;
in.params.play.adsr.decaySeconds = 0.02;
in.params.play.adsr.sustainLevel = 0.8;
in.params.play.adsr.releaseSeconds = 0.15;
in.params.play.trigger.lengthFraction = 0.75;
in.params.play.trigger.fadeInFrames = 100;
in.params.play.trigger.fadeOutFrames = 200;
in.params.play.pitchEngine = PitchEngine::Preserve;
in.params.play.pitchEnv.enabled = true;
in.params.play.pitchEnv.attackSeconds = 0.02;
in.params.play.pitchEnv.decaySeconds = 0.03;
in.params.play.pitchEnv.peakSemitones = 5.0;
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
// clang-format off
@@ -206,30 +388,19 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00,
0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00,
0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,
0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,
0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,
0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,
0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,
0x00,0xc0,0x5f,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x05,0x00,0x00,0x00,
0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,
0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1,
0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00,
0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x40,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,0x00,
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -242,11 +413,29 @@ static void testGoldenFullBlobFixture() {
}
}
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in
// either is a byte-format break the round-trip alone can't prove (both sides could
// drift together). Pins the writer's absolute bytes.
// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
// blob restores as the silent empty state, not as a set of accidental values.
static void testDefaultStateRoundTripsToDefaults() {
const ComponentState out =
deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
CHECK(out.selectionId.empty());
CHECK(!out.params.rootOverride);
CHECK(!out.params.loopOverride);
CHECK(!out.params.startPoint);
CHECK(out.params.keyTrack == 1.0);
CHECK(out.params.play.playMode == PlayMode::Gate);
CHECK(out.params.play.pitchEngine == kDefaultPitchEngine);
CHECK(!out.params.play.pitchEnv.enabled);
CHECK(out.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(out.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
}
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in either is
// a byte-format break the round-trip alone can't prove (both sides could drift together).
// Also pins the payload version + marker as SEMANTIC constants, so a bump has to be
// deliberate rather than incidental.
static void testEnvelopePrefixBytesFrozen() {
ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones
ComponentState in; // defaults: mono, implicit, no refs, no selection, default params
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() > 5);
if (bytes.size() > 5) {
@@ -254,64 +443,479 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kZonesPayloadVersion == 7);
CHECK(kZonesFormatMarker == 0xFFFFFF00u);
CHECK(kParamsPayloadVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
}
// A v1 selection blob lifts to {id, one full-keyboard zone} — the oldest live lift.
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
static void testWriterEmitsCurrentPayloadVersion() {
ComponentState in;
in.selectionId = "id";
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
// Scan for the marker; the four bytes after it are the payload version.
bool found = false;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
if (m != kParamsFormatMarker) continue;
const std::uint32_t v = static_cast<std::uint32_t>(bytes[i + 4]) |
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
CHECK(v == kParamsPayloadVersion);
found = true;
break;
}
CHECK(found);
}
// --- The retired-zone-payload migration ladder --------------------------------
// SINGLE-ZONE LIFT IS LOSSLESS: a single-capture instance saved under the zone model
// restores with the same capture, the same root, and the same parameters.
static void testSingleZoneMigrationIsLossless() {
legacy::Zone z;
z.sampleId = "kick";
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = 36;
z.hasLoopOverride = true;
z.loopStart = 1000;
z.loopEnd = 5000;
z.startPoint = 250;
z.trigger = true;
z.holdSeconds = 0.05;
z.lengthFraction = 0.75;
z.fadeIn = 100;
z.fadeOut = 200;
z.preserve = true;
z.pitchEnvEnabled = true;
z.pitchAttack = 0.02;
z.pitchDecay = 0.03;
z.peakSemis = 5.0;
z.attackSeconds = 0.01;
z.decaySeconds = 0.02;
z.sustainLevel = 0.8;
z.releaseSeconds = 0.15;
z.keyTrack = 0.5;
z.curve = {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}};
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // same capture
const InstrumentParams& p = out.params;
CHECK(p.rootOverride && *p.rootOverride == 36); // same root
CHECK(p.loopOverride && p.loopOverride->start == 1000 && p.loopOverride->end == 5000);
CHECK(p.startPoint && *p.startPoint == 250);
CHECK(p.keyTrack == 0.5);
CHECK(p.velocityCurve.points().size() == 3);
CHECK(p.play.playMode == PlayMode::Trigger);
CHECK(p.play.adsr.attackSeconds == 0.01);
CHECK(p.play.adsr.holdSeconds == 0.05);
CHECK(p.play.adsr.decaySeconds == 0.02);
CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(p.play.adsr.releaseSeconds == 0.15);
CHECK(p.play.trigger.lengthFraction == 0.75);
CHECK(p.play.trigger.fadeInFrames == 100);
CHECK(p.play.trigger.fadeOutFrames == 200);
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
CHECK(p.play.pitchEnv.enabled);
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
}
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
// false — distinct from no override at all (which leaves the sample's own intrinsic loop in
// force). The writer always emitted the override's inner hasLoop bit as true; this is the
// disabled shape it never exercised.
static void testSingleZoneMigrationLiftsLoopDisablingOverride() {
legacy::Zone z;
z.sampleId = "kick";
z.hasLoopOverride = true;
z.loopOverrideHasLoop = false;
z.loopStart = 1000;
z.loopEnd = 5000;
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
const InstrumentParams& p = out.params;
CHECK(p.loopOverride.has_value());
CHECK(p.loopOverride && !p.loopOverride->hasLoop);
}
// A lifted single-zone instance RE-SAVES in the current format and survives a second
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
static void testLiftedStateReSavesInCurrentFormat() {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
z.keyTrack = 0.5;
z.releaseSeconds = 0.4;
const ComponentState lifted =
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
const ComponentState again =
deserializeComponentState(serializeComponentState(lifted), 48000.0);
CHECK(again.selectionId == "kick");
CHECK(again.params.rootOverride && *again.params.rootOverride == 36);
CHECK(again.params.keyTrack == 0.5);
CHECK(again.params.play.adsr.releaseSeconds == 0.4);
}
// MULTI-ZONE LIFT ADOPTS ZONE ONE: its capture AND its parameters win; every later zone
// drops. No error, no empty state.
static void testMultiZoneMigrationAdoptsFirstZone() {
legacy::Zone first;
first.sampleId = "kick";
first.lowNote = 0;
first.highNote = 59;
first.rootOverride = 36;
first.keyTrack = 0.5;
first.releaseSeconds = 0.4;
legacy::Zone second;
second.sampleId = "snare";
second.lowNote = 60;
second.highNote = 127;
second.rootOverride = 38;
second.keyTrack = 2.0;
second.releaseSeconds = 0.9;
legacy::Zone third;
third.sampleId = "hat";
third.rootOverride = 42;
const ComponentState out = deserializeComponentState(
legacy::envelopeWithZones("", {first, second, third}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // zone one's capture
CHECK(out.params.rootOverride && *out.params.rootOverride == 36);
CHECK(out.params.keyTrack == 0.5); // zone one's parameters
CHECK(out.params.play.adsr.releaseSeconds == 0.4);
// Zones two and three left no trace anywhere.
CHECK(out.selectionId != "snare" && out.selectionId != "hat");
CHECK(out.params.keyTrack != 2.0);
}
// The FIRST zone supersedes the envelope's own stored selection — that zone is what
// first-match resolve actually played, so adopting it is what keeps the sound identical.
static void testFirstZoneSupersedesStoredSelection() {
legacy::Zone z;
z.sampleId = "actually-playing";
const ComponentState out = deserializeComponentState(
legacy::envelopeWithZones("stale-selection", {z}, 7), 48000.0);
CHECK(out.selectionId == "actually-playing");
}
// An EMPTY zone list leaves the envelope's selection alone (a picked-but-never-edited
// instance) and yields default parameters.
static void testEmptyZoneListKeepsTheStoredSelection() {
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("picked", {}, 7), 48000.0);
CHECK(out.selectionId == "picked");
CHECK(!out.params.rootOverride);
CHECK(out.params.keyTrack == 1.0);
}
// EVERY older payload version takes the migration path, and each lifts the fields its own
// shape carries while defaulting the ones it predates.
static void testEveryOlderPayloadVersionMigrates() {
for (std::uint32_t pv : {1u, 2u, 5u, 6u, 7u}) {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
z.keyTrack = 0.5;
z.releaseSeconds = 0.4;
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones("", {z}, pv), 48000.0);
CHECK(out.selectionId == "kick"); // every version
CHECK(out.params.rootOverride && *out.params.rootOverride == 36); // v1 onward
// keyTrack arrived at v6; older payloads lift to 100% ET (bit-identical repitch).
CHECK(out.params.keyTrack == (pv >= 6 ? 0.5 : 1.0));
// The full A/D/S/R tail arrived at v5; older payloads keep the tier-0 defaults.
CHECK(out.params.play.adsr.releaseSeconds ==
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
}
// And the CURRENT version does NOT take the migration path: it reads its own record.
CHECK(kParamsPayloadVersion == 8);
}
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
// using the project rate threaded in — no baked constant.
static void testLegacyV3FramesConvertAtTheProjectRate() {
// v3's own tail shape differs from v5's, so lay it out directly here.
std::vector<std::uint8_t> out;
legacy::u32v(out, kComponentStateVersion);
legacy::u8v(out, 0);
legacy::i64v(out, 0);
legacy::u8v(out, kPreviewVelocityDefault);
legacy::u8v(out, static_cast<std::uint8_t>(kDefaultVoiceCount));
legacy::u8v(out, 0);
legacy::u8v(out, 0);
legacy::f64v(out, 1.0);
legacy::u8v(out, 0);
legacy::u32v(out, 0);
legacy::strv(out, "");
legacy::strv(out, "");
legacy::u32v(out, kParamsFormatMarker);
legacy::u32v(out, 3);
legacy::u32v(out, 1); // one zone
legacy::strv(out, "kick");
legacy::u32v(out, 0); // lowNote
legacy::u32v(out, 127); // highNote
legacy::u8v(out, 0); // no root override
legacy::u8v(out, 0); // no loop override
legacy::u8v(out, 0); // no start point
legacy::u8v(out, 0); // playMode: Gate
legacy::i64v(out, 2400); // holdFrames -> 0.05 s at 48 kHz
legacy::f64v(out, 1.0); // lengthFraction
legacy::i64v(out, 0); // fadeIn
legacy::i64v(out, 0); // fadeOut
legacy::u8v(out, 1); // pitchEngine: Preserve
legacy::u8v(out, 1); // pitchEnv enabled
legacy::i64v(out, 960); // pitchEnv attackFrames -> 0.02 s
legacy::i64v(out, 1440); // pitchEnv decayFrames -> 0.03 s
legacy::f64v(out, 5.0); // peakSemitones
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.play.adsr.holdSeconds == 0.05);
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
CHECK(st.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
// The SAME bytes at a different project rate convert to different seconds — proof the
// rate is a read-time parameter, not a baked constant.
const ComponentState at96k = deserializeComponentState(out, 96000.0);
CHECK(at96k.params.play.adsr.holdSeconds == 0.025);
}
// --- The ENVELOPE ladder (v2..v11) -------------------------------------------
// EVERY envelope version restores the fields it carried and lifts the ones it predates to
// their documented defaults. One table over the whole ladder, so a new envelope field
// cannot be added without deciding what each older version lifts it to.
static void testEnvelopeLadderLiftsEachVersion() {
for (std::uint32_t v : {3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u}) {
legacy::Envelope env;
env.version = v;
env.selectionId = "kick";
env.modeByte = 1; // stereo
env.assignGeneration = 4242;
env.previewVelocity = 99;
env.voiceCount = 7;
env.voiceMode = 1; // mono
env.monoTrigger = 1; // legato
env.masterGain = 0.5;
env.channelModeExplicit = 1;
env.instanceGuid = "guid-abc";
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.selectionId == "kick"); // v3 onward all carry the selection
// v4 added the channel mode; older blobs lift to MONO.
CHECK(out.channelMode == (v >= 4 ? ChannelMode::Stereo : ChannelMode::Mono));
// v5 added the consumed-assignment marker; older blobs lift to 0, so a genuinely
// new first assign (generation >= 1) still applies to a pre-marker instance.
CHECK(out.lastConsumedAssignGeneration == (v >= 5 ? 4242 : 0));
// v6 added the preview velocity; older blobs lift to the mid default.
CHECK(out.previewVelocity == (v >= 6 ? 99 : kPreviewVelocityDefault));
// v7 added the voice system; older blobs lift to {16, Poly, Retrigger} — the
// pre-voice-system behavior, byte-identically.
CHECK(out.voiceCount == (v >= 7 ? 7 : kDefaultVoiceCount));
CHECK(out.voiceMode == (v >= 7 ? VoiceMode::Mono : VoiceMode::Poly));
CHECK(out.monoTrigger == (v >= 7 ? MonoTrigger::Legato : MonoTrigger::Retrigger));
// v8 added the master gain; older blobs lift to unity.
CHECK(out.masterGainLinear == (v >= 8 ? 0.5 : 1.0));
// v9 added the channel-mode EXPLICIT flag; older blobs lift to implicit, so the
// auto-default may follow the loaded capture.
CHECK(out.channelModeExplicit == (v >= 9 ? true : false));
// v10 added the refs table (always empty here), v11 the instance guid; a pre-v11
// blob lifts to an empty guid, which the processor mints on first publish.
CHECK(out.instanceGuid == (v >= 11 ? "guid-abc" : ""));
CHECK(out.sampleRefs.empty());
}
}
// A v2 blob is ZONES-ONLY — no stored selection at all — so the adopted first zone supplies
// BOTH the capture and the parameters.
static void testV2ZonesOnlyBlobAdoptsBothFromZoneOne() {
legacy::Zone z;
z.sampleId = "kick";
z.rootOverride = 36;
std::vector<std::uint8_t> out;
legacy::u32v(out, kPerformanceStateVersion); // == 2, the zones-only envelope
legacy::u32v(out, kParamsFormatMarker);
legacy::u32v(out, 7);
legacy::u32v(out, 1);
legacy::putZone(out, z, 7);
const ComponentState st = deserializeComponentState(out, 48000.0);
CHECK(st.selectionId == "kick");
CHECK(st.params.rootOverride && *st.params.rootOverride == 36);
CHECK(st.channelMode == ChannelMode::Mono); // a v2 blob predates the mode byte
}
// A CORRUPT field falls back to its own DEFAULT rather than clamping to an edge the user
// never chose (or, for the gain, silencing/blasting the instance).
static void testCorruptFieldsFallBackToDefaults() {
legacy::Envelope env;
env.selectionId = "kick";
env.previewVelocity = 0; // 0 is a note-off by convention — out of the 1..127 spec
env.voiceCount = 200; // past kMaxVoiceCount
env.masterGain = 1e9; // far past the +24 dB cap
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.previewVelocity == kPreviewVelocityDefault);
CHECK(out.voiceCount == kDefaultVoiceCount);
CHECK(out.masterGainLinear == 1.0);
}
// CORRUPT-BLOB posture for the refs-table intrinsics: the refs table is the ONLY copy on the
// play path, so a bad field must degrade to its own default, never poison playback. An
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a negative
// channelCount falls back to 0 = unknown (the GA auto-default then skips it). The fallback is
// per-field — in-range neighbours pass through untouched.
static void testSampleRefsReaderRangeFallbacks() {
ComponentState s;
s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
/*channels=*/-3));
s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
/*channels=*/1));
s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
/*channels=*/2));
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
CHECK(back.sampleRefs.size() == 3);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
}
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest of
// the state empty (the selection/params behind the cut are unreadable anyway) — the
// established truncation posture, never a throw across the host boundary.
static void testSampleRefsTruncatedMidEntry() {
ComponentState s;
s.selectionId = "kick";
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
std::vector<std::uint8_t> bytes = serializeComponentState(s);
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes;
// entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0).
// Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31).
CHECK(bytes.size() > 178);
bytes.resize(bytes.size() - 178);
const ComponentState back = deserializeComponentState(bytes, 44100.0);
CHECK(back.sampleRefs.size() == 1);
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
CHECK(back.selectionId.empty());
CHECK(!back.params.rootOverride);
}
// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
// produced always re-reads as itself.
static void testWriterClampsOutOfRangeFields() {
ComponentState in;
in.voiceCount = 999;
in.masterGainLinear = 1e9;
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.voiceCount >= kMinVoiceCount && out.voiceCount <= kMaxVoiceCount);
CHECK(out.masterGainLinear <=
reasampler::instrument::engine::masterGainMaxLinear() * (1.0 + 1e-9));
// Zero gain is TRUE silence and a legal stored value — it must not be "corrected".
ComponentState silent;
silent.masterGainLinear = 0.0;
CHECK(deserializeComponentState(serializeComponentState(silent), 48000.0)
.masterGainLinear == 0.0);
}
// An UNKNOWN envelope version yields the empty state rather than a misparse.
static void testUnknownEnvelopeVersionIsEmpty() {
legacy::Envelope env;
env.version = 99;
env.selectionId = "kick";
const ComponentState out =
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
CHECK(out.selectionId.empty());
CHECK(!out.params.rootOverride);
}
// A v1 selection blob lifts to {id, default params} — the oldest live lift.
static void testV1SelectionLift() {
const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
const ComponentState out = deserializeComponentState(v1, 48000.0);
CHECK(out.selectionId == "old-pick");
CHECK(out.map.zones.size() == 1);
if (out.map.zones.size() == 1) {
CHECK(out.map.zones[0].sampleId == "old-pick");
CHECK(out.map.zones[0].lowNote == 0);
CHECK(out.map.zones[0].highNote == 127);
}
CHECK(!out.params.rootOverride);
CHECK(out.params.keyTrack == 1.0);
}
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws.
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
// over BOTH the current format and a retired zone-list blob, since the migration path has
// its own bounded-read walk. Beyond mere survival, a cut read must never RETAIN more refs
// than the blob actually carried (the "keep what parsed, drop the rest" contract could not
// silently start fabricating entries) — see testSampleRefsTruncatedMidEntry for the exact
// mid-entry retention case this bounds only loosely across every cut point.
static void testTruncationDegradesCleanly() {
ComponentState in;
in.selectionId = "smp-2";
PerformanceZone z;
z.sampleId = "smp-2";
in.map.zones.push_back(z);
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
for (std::size_t cut = 0; cut < bytes.size(); ++cut) {
const std::vector<std::uint8_t> part(bytes.begin(),
bytes.begin() + static_cast<long>(cut));
const ComponentState out = deserializeComponentState(part, 48000.0);
(void)out; // reaching here without UB/throw is the contract under test
}
CHECK(true);
}
in.params.rootOverride = 61;
in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
const std::vector<std::uint8_t> current = serializeComponentState(in);
// serializePerformance/deserializePerformance round-trip through the v2 envelope.
static void testPerformanceRoundTrip() {
PerformanceMap in;
PerformanceZone z;
z.sampleId = "zone-a";
z.lowNote = 10;
z.highNote = 20;
in.zones.push_back(z);
const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0);
CHECK(out.zones.size() == 1);
if (out.zones.size() == 1) {
CHECK(out.zones[0].sampleId == "zone-a");
CHECK(out.zones[0].lowNote == 10);
CHECK(out.zones[0].highNote == 20);
legacy::Zone z;
z.sampleId = "smp-2";
const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);
for (const std::vector<std::uint8_t>* blob : {&current, &retired}) {
for (std::size_t cut = 0; cut < blob->size(); ++cut) {
const std::vector<std::uint8_t> part(blob->begin(),
blob->begin() + static_cast<long>(cut));
const ComponentState out = deserializeComponentState(part, 48000.0);
CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
}
}
}
int main() {
testComponentStateRoundTrip();
testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testSingleZoneMigrationLiftsLoopDisablingOverride();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
testFirstZoneSupersedesStoredSelection();
testEmptyZoneListKeepsTheStoredSelection();
testEveryOlderPayloadVersionMigrates();
testLegacyV3FramesConvertAtTheProjectRate();
testEnvelopeLadderLiftsEachVersion();
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
testCorruptFieldsFallBackToDefaults();
testSampleRefsReaderRangeFallbacks();
testSampleRefsTruncatedMidEntry();
testWriterClampsOutOfRangeFields();
testUnknownEnvelopeVersionIsEmpty();
testV1SelectionLift();
testTruncationDegradesCleanly();
testPerformanceRoundTrip();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;
-413
View File
@@ -1,413 +0,0 @@
// Standalone tests for reasampler::instrument::ui::editor_geometry — no VST3, no REAPER, no test
// framework. Same fast assert loop as the sibling pure tests (mode_switch et al.):
// assert the IPlugView LICE editor's layout math + hit-testing directly.
//
// Covers: contains() half-open convention + degenerate rects; layoutEditor regions on a
// normal view (title band + button + canvas), a tiny view (button clamped to canvas,
// never overhanging), and a zero view (all rects empty, no inversion); hitTest hitting
// the button, missing on the title/canvas, missing outside the surface, and boundary
// pixels; layout<->hit-test agreement (a click on the drawn button rect hits it).
#include "../src/core/instrument/ui/editor_geometry.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- contains() ---------------------------------------------------------------
static void testContainsHalfOpen() {
Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40)
CHECK(contains(r, 10, 20)); // top-left inclusive
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
CHECK(!contains(r, 50, 30)); // right edge excluded
CHECK(!contains(r, 30, 40)); // bottom edge excluded
CHECK(!contains(r, 9, 30)); // left of rect
CHECK(!contains(r, 30, 19)); // above rect
}
static void testContainsDegenerate() {
CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width
CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height
CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left)
}
// --- layoutEditor: normal view ------------------------------------------------
static void testLayoutNormalView() {
// A comfortable 400x260 view: title band spans the top full width; canvas is the
// rest; button sits inside the canvas, inset by the margin.
const EditorLayout L = layoutEditor(400, 260);
CHECK(L.titleBar.x == 0 && L.titleBar.y == 0);
CHECK(L.titleBar.right() == 400);
CHECK(L.titleBar.height > 0 && L.titleBar.height <= 260);
// Canvas begins right below the title bar and reaches the bottom-right.
CHECK(L.canvas.y == L.titleBar.bottom());
CHECK(L.canvas.right() == 400 && L.canvas.bottom() == 260);
// Button is inside the canvas (does not overhang any edge).
CHECK(L.button.x >= L.canvas.x);
CHECK(L.button.y >= L.canvas.y);
CHECK(L.button.right() <= L.canvas.right());
CHECK(L.button.bottom() <= L.canvas.bottom());
CHECK(L.button.width > 0 && L.button.height > 0);
}
// --- layoutEditor: tiny view (clamping) ---------------------------------------
static void testLayoutTinyViewClampsButton() {
// A view narrower/shorter than the button's natural size: the button must clamp to
// the canvas and never produce an inverted or overhanging rect.
const EditorLayout L = layoutEditor(40, 40);
CHECK(L.button.right() <= L.canvas.right());
CHECK(L.button.bottom() <= L.canvas.bottom());
CHECK(L.button.right() >= L.button.x); // never inverted
CHECK(L.button.bottom() >= L.button.y);
// Title bar clamps to the client height when the view is shorter than its height.
CHECK(L.titleBar.bottom() <= 40);
}
// --- layoutEditor: zero view (all empty, no inversion) ------------------------
static void testLayoutZeroView() {
const EditorLayout L = layoutEditor(0, 0);
CHECK(L.titleBar.width <= 0 || L.titleBar.height <= 0);
CHECK(L.canvas.width <= 0 || L.canvas.height <= 0);
// No rect is inverted.
CHECK(L.button.right() >= L.button.x);
CHECK(L.button.bottom() >= L.button.y);
CHECK(L.canvas.right() >= L.canvas.x);
CHECK(L.canvas.bottom() >= L.canvas.y);
// A click anywhere on an empty layout hits nothing.
CHECK(hitTest(L, 0, 0) == HitTarget::kNone);
CHECK(hitTest(L, 5, 5) == HitTarget::kNone);
}
// --- hitTest ------------------------------------------------------------------
static void testHitTestButton() {
const EditorLayout L = layoutEditor(400, 260);
// Center of the button hits it.
const int cx = (L.button.x + L.button.right()) / 2;
const int cy = (L.button.y + L.button.bottom()) / 2;
CHECK(hitTest(L, cx, cy) == HitTarget::kButton);
}
static void testHitTestMissesNonButton() {
const EditorLayout L = layoutEditor(400, 260);
// Title bar is inert in the spike.
CHECK(hitTest(L, 200, L.titleBar.y + 1) == HitTarget::kNone);
// Empty canvas away from the button.
CHECK(hitTest(L, 380, 240) == HitTarget::kNone);
// Outside the surface entirely.
CHECK(hitTest(L, -5, -5) == HitTarget::kNone);
CHECK(hitTest(L, 500, 500) == HitTarget::kNone);
}
static void testHitTestButtonBoundary() {
const EditorLayout L = layoutEditor(400, 260);
// Top-left corner of the button is inclusive; the right/bottom edges are excluded.
CHECK(hitTest(L, L.button.x, L.button.y) == HitTarget::kButton);
CHECK(hitTest(L, L.button.right(), L.button.y) == HitTarget::kNone);
CHECK(hitTest(L, L.button.x, L.button.bottom()) == HitTarget::kNone);
}
// --- layout<->hit-test agreement ----------------------------------------------
// Every pixel inside the drawn button rect must hit the button; this is the
// load-bearing consistency invariant between what the shell draws and what it routes.
static void testHitTestMatchesDrawnButton() {
const EditorLayout L = layoutEditor(320, 200);
for (int y = L.button.y; y < L.button.bottom(); ++y) {
for (int x = L.button.x; x < L.button.right(); ++x) {
CHECK(hitTest(L, x, y) == HitTarget::kButton);
}
}
}
// --- sample list (S4) ---------------------------------------------------------
static void testSampleRowRectStacks() {
const EditorLayout L = layoutEditor(400, 260);
const Rect r0 = sampleRowRect(L, 0);
const Rect r1 = sampleRowRect(L, 1);
// Row 0 starts at the canvas top and spans its full width.
CHECK(r0.y == L.canvas.y);
CHECK(r0.x == L.canvas.x && r0.right() == L.canvas.right());
CHECK(r0.height == kSampleRowHeight);
// Row 1 sits directly below row 0 (no gap, no overlap).
CHECK(r1.y == r0.bottom());
CHECK(r1.height == kSampleRowHeight);
// A negative index is an empty rect.
CHECK(sampleRowRect(L, -1).width == 0 && sampleRowRect(L, -1).height == 0);
}
static void testSampleRowHitTestMapsClickToRow() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 5;
// A click in the vertical middle of row 2 resolves to index 2.
const Rect r2 = sampleRowRect(L, 2);
const int midY = (r2.y + r2.bottom()) / 2;
CHECK(sampleRowHitTest(L, rows, 200, midY) == 2);
// Row 0's top-left corner hits row 0.
const Rect r0 = sampleRowRect(L, 0);
CHECK(sampleRowHitTest(L, rows, r0.x, r0.y) == 0);
}
static void testSampleRowHitTestMisses() {
const EditorLayout L = layoutEditor(400, 260);
const int rows = 3;
// Above the first row (in the title bar) -> no row.
CHECK(sampleRowHitTest(L, rows, 200, L.titleBar.y) == -1);
// Below the last row -> no row.
const Rect last = sampleRowRect(L, rows - 1);
CHECK(sampleRowHitTest(L, rows, 200, last.bottom() + 1) == -1);
// Left of the canvas -> no row.
CHECK(sampleRowHitTest(L, rows, L.canvas.x - 1, last.y) == -1);
// Zero rows -> always -1.
CHECK(sampleRowHitTest(L, 0, 200, L.canvas.y + 1) == -1);
// At or below canvas.bottom() -> always -1, even if rowCount would cover that y.
// This guards paint<->hit-test agreement: sampleRowRect does not clamp to canvas,
// so without this clip a row that extends past canvas.bottom() would hit-test but
// never be drawn (or vice versa).
CHECK(sampleRowHitTest(L, rows, 200, L.canvas.bottom()) == -1);
// Use a large rowCount so index arithmetic would return a valid row without the
// canvas.bottom() guard — proving the guard fires independently of rowCount.
const int bigRows = 1000;
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom()) == -1);
CHECK(sampleRowHitTest(L, bigRows, 200, L.canvas.bottom() + 5) == -1);
}
// The drawn-row <-> hit-test agreement: every pixel inside a row rect must resolve to
// that row's index (the same load-bearing invariant as the button).
static void testSampleRowHitTestMatchesDrawnRows() {
const EditorLayout L = layoutEditor(320, 200);
const int rows = 4;
for (int i = 0; i < rows; ++i) {
const Rect r = sampleRowRect(L, i);
if (r.y >= L.canvas.bottom()) break; // clipped rows aren't clickable targets
const int y = (r.y + r.bottom()) / 2;
if (y >= L.canvas.bottom()) continue;
CHECK(sampleRowHitTest(L, rows, r.x + 1, y) == i);
}
}
// --- keymap editor (S5 Tier-1 UI) --------------------------------------------
static void testKeymapLayoutSplitsCanvas() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
// The left sample list and right zone panel partition the canvas with no overlap and
// no gap: the list's right edge is the panel's left edge.
CHECK(L.sampleList.x == L.base.canvas.x);
CHECK(L.sampleList.right() == L.zonePanel.x);
CHECK(L.zonePanel.right() == L.base.canvas.right());
CHECK(L.sampleList.y == L.base.canvas.y);
CHECK(L.zonePanel.y == L.base.canvas.y);
CHECK(L.sampleList.bottom() == L.base.canvas.bottom());
CHECK(L.zonePanel.bottom() == L.base.canvas.bottom());
CHECK(L.sampleList.width > 0 && L.zonePanel.width > 0);
// Add-Zone button caps the panel; zone rows stack below it.
CHECK(L.addZoneButton.y == L.zonePanel.y);
CHECK(L.addZoneButton.x == L.zonePanel.x && L.addZoneButton.right() == L.zonePanel.right());
CHECK(L.zoneRowArea.y == L.addZoneButton.bottom());
CHECK(L.zoneRowArea.bottom() == L.zonePanel.bottom());
}
static void checkNoInversion(const KeymapEditorLayout& L) {
CHECK(L.sampleList.right() >= L.sampleList.x);
CHECK(L.zonePanel.right() >= L.zonePanel.x);
CHECK(L.addZoneButton.right() >= L.addZoneButton.x);
CHECK(L.addZoneButton.bottom() >= L.addZoneButton.y);
CHECK(L.zoneRowArea.right() >= L.zoneRowArea.x);
CHECK(L.zoneRowArea.bottom() >= L.zoneRowArea.y);
// Regions stay within the client area.
CHECK(L.zonePanel.right() <= L.base.canvas.right());
}
static void testKeymapLayoutTinyAndZeroNoInversion() {
checkNoInversion(layoutKeymapEditor(30, 30));
checkNoInversion(layoutKeymapEditor(0, 0));
// A click anywhere on a zero layout hits no zone and no Add button.
const KeymapEditorLayout Z = layoutKeymapEditor(0, 0);
CHECK(zoneHitTest(Z, 3, 0, 0).zoneIndex == -1);
CHECK(!addZoneHitTest(Z, 0, 0));
}
static void testKeymapSampleRowInLeftColumn() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect r0 = keymapSampleRowRect(L, 0);
// Rows live in the LEFT column (not the full canvas width).
CHECK(r0.x == L.sampleList.x && r0.right() == L.sampleList.right());
CHECK(r0.right() < L.base.canvas.right()); // strictly left of the zone panel
CHECK(r0.y == L.sampleList.y && r0.height == kSampleRowHeight);
// Hit-test maps a left-column click to the row and rejects a click in the zone panel.
const int midY = (r0.y + r0.bottom()) / 2;
CHECK(keymapSampleRowHitTest(L, 3, r0.x + 2, midY) == 0);
CHECK(keymapSampleRowHitTest(L, 3, L.zonePanel.x + 2, midY) == -1);
}
static void testAddZoneHitTest() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const int cx = (L.addZoneButton.x + L.addZoneButton.right()) / 2;
const int cy = (L.addZoneButton.y + L.addZoneButton.bottom()) / 2;
CHECK(addZoneHitTest(L, cx, cy));
// A click in the zone-row area below the button is NOT the Add button.
CHECK(!addZoneHitTest(L, cx, L.zoneRowArea.y + 2));
// A click in the left list is NOT the Add button.
CHECK(!addZoneHitTest(L, L.sampleList.x + 2, L.sampleList.y + 2));
}
static void testZoneRowStacksAndSelects() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect z0 = zoneRowRect(L, 0);
const Rect z1 = zoneRowRect(L, 1);
CHECK(z0.y == L.zoneRowArea.y && z0.height == kZoneRowHeight);
CHECK(z1.y == z0.bottom()); // stacked, no gap
CHECK(z0.x == L.zoneRowArea.x && z0.right() == L.zoneRowArea.right());
// A click on the LABEL area (left part of a zone row) selects the zone with no field.
const int labelX = z0.x + 2; // far left = label, not a control
const int midY = (z0.y + z0.bottom()) / 2;
const ZoneHit h = zoneHitTest(L, 2, labelX, midY);
CHECK(h.zoneIndex == 0 && h.field == ZoneField::kZoneNone);
}
static void testZoneRowControlsMapToFields() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect row = zoneRowRect(L, 0);
const int midY = (row.y + row.bottom()) / 2;
// The seven controls occupy the rightmost 7*kZoneCtrlWidth px, left-to-right:
// low-, low+, high-, high+, root-, root+, delete.
const int block = row.right() - 7 * kZoneCtrlWidth;
const ZoneField expected[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
ZoneField::kDelete,
};
for (int s = 0; s < 7; ++s) {
const int x = block + s * kZoneCtrlWidth + kZoneCtrlWidth / 2; // center of slot s
const ZoneHit h = zoneHitTest(L, 1, x, midY);
CHECK(h.zoneIndex == 0);
CHECK(h.zoneIndex == 0 && h.field == expected[s]);
}
}
static void testZoneHitTestMisses() {
const KeymapEditorLayout L = layoutKeymapEditor(600, 300);
const Rect row = zoneRowRect(L, 0);
const int midY = (row.y + row.bottom()) / 2;
// Zero zones -> always miss.
CHECK(zoneHitTest(L, 0, row.x + 2, midY).zoneIndex == -1);
// Below the last zone row -> miss.
const Rect last = zoneRowRect(L, 2);
CHECK(zoneHitTest(L, 3, row.x + 2, last.bottom() + 1).zoneIndex == -1);
// Left of the zone panel (in the sample list) -> miss.
CHECK(zoneHitTest(L, 3, L.sampleList.x + 2, midY).zoneIndex == -1);
}
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
// The band stack at the default 840x620 with a 120px deck: title / hero / cluster /
// deck in order, hero elastic (absorbs the slack), deck bottom-anchored at kPad.
static void testSampleBandsStackAndElasticHero() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.title.y == 0 && b.title.height == kTitleHeight && b.title.width == 840);
CHECK(b.hero.y == b.title.bottom());
CHECK(b.hero.height >= 150); // above the hero floor
CHECK(b.cluster.y > b.hero.bottom()); // cluster below the hero (+gap)
CHECK(b.deck.bottom() == 620 - kPad); // deck bottom-anchored
CHECK(b.deck.height == 120);
// Nav buttons right-anchored inside the title band, Browse left of Zone.
CHECK(b.navZone.right() == 840 - kPad);
CHECK(b.navBrowse.right() < b.navZone.x);
CHECK(b.navZone.bottom() <= b.title.bottom());
// A too-short window: the hero keeps its floor; the lower bands clip below.
const SampleBands s = computeSampleBands(840, 200, 120);
CHECK(s.hero.height == 150);
CHECK(s.deck.bottom() > 200); // clips past the window bottom (defensive case)
}
// The cluster's right-anchored run tiles left of the channel toggle without overlap:
// rootStrip | preview | velCell(velKnob+velLabel) | curveBtn | (toggle).
static void testClusterRectsRunAndKnobCentering() {
const Rect cluster = Rect::ltrb(0, 500, 840, 552);
const ChannelToggleRects chan = channelToggleRects(cluster);
CHECK(chan.stereo.right() == 840 - kPad);
CHECK(chan.mono.right() == chan.stereo.x);
const ClusterRects cr = clusterRects(cluster, chan.mono, 28);
CHECK(cr.curveBtn.right() == chan.mono.x - kPad);
CHECK(cr.velCell.right() == cr.curveBtn.x - kPad);
CHECK(cr.preview.right() == cr.velCell.x - kPad);
CHECK(cr.rootStrip.x == cluster.x + kPad);
CHECK(cr.rootStrip.right() == cr.preview.x - kPad);
// The knob square centers in the cell and the label band sits beneath it.
CHECK(cr.velKnob.width == 28);
CHECK(cr.velKnob.x - cr.velCell.x == cr.velCell.right() - cr.velKnob.right());
CHECK(cr.velLabel.y == cr.velKnob.bottom());
CHECK(cr.velLabel.bottom() == cr.velCell.bottom());
}
// The Zone surface: content below the title; strip below the add/delete row; the note
// entry fields tile in three ordered segments; deck + curve button split the panel.
static void testZoneSurfaceLayoutAnchors() {
const Rect content = zoneContentArea(840, 620);
CHECK(content.y == kTitleHeight && content.bottom() == 620);
const Rect back = zoneBackRect(840, 620);
CHECK(back.right() == 840 - kPad && back.bottom() <= kTitleHeight);
const Rect addR = zoneAddRect(content);
const Rect delR = zoneDeleteRect(addR);
CHECK(addR.y == content.y + 4);
CHECK(delR.x == addR.right() + 8 && delR.y == addR.y);
const Rect strip = zonesStripArea(content);
CHECK(strip.y == addR.bottom() + 12);
CHECK(strip.x == content.x + kPad && strip.right() == content.right() - kPad);
const Rect fields = noteEntryFieldsArea(content);
CHECK(fields.y == strip.bottom() + 8);
const Rect f0 = noteEntryFieldRect(fields, 0);
const Rect f1 = noteEntryFieldRect(fields, 1);
const Rect f2 = noteEntryFieldRect(fields, 2);
CHECK(f0.x < f1.x && f1.x < f2.x);
CHECK(f2.right() == fields.right());
CHECK(noteEntryFieldRect(fields, 3).width == 0); // out-of-range -> empty
const Rect panel = zonesControlPanel(content);
const Rect deck = zonesDeckArea(content);
const Rect curve = zonesCurveButton(content);
CHECK(panel.y == strip.bottom() + 8 + 18 + 8);
CHECK(deck.y == panel.y && deck.right() < curve.x); // curve column reserved
CHECK(curve.right() == panel.right() && curve.y == panel.y);
}
int main() {
testContainsHalfOpen();
testContainsDegenerate();
testLayoutNormalView();
testLayoutTinyViewClampsButton();
testLayoutZeroView();
testHitTestButton();
testHitTestMissesNonButton();
testHitTestButtonBoundary();
testHitTestMatchesDrawnButton();
testSampleRowRectStacks();
testSampleRowHitTestMapsClickToRow();
testSampleRowHitTestMisses();
testSampleRowHitTestMatchesDrawnRows();
testKeymapLayoutSplitsCanvas();
testKeymapLayoutTinyAndZeroNoInversion();
testKeymapSampleRowInLeftColumn();
testAddZoneHitTest();
testZoneRowStacksAndSelects();
testZoneRowControlsMapToFields();
testZoneHitTestMisses();
testSampleBandsStackAndElasticHero();
testClusterRectsRunAndKnobCentering();
testZoneSurfaceLayoutAnchors();
if (g_fail == 0) std::printf("editor_geometry: all tests passed\n");
return g_fail != 0;
}
+30 -59
View File
@@ -1,12 +1,11 @@
// Standalone tests for reasampler::instrument::ui::embed_strip — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the
// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly.
// Same fast assert loop as the sibling pure tests: assert the embedded TCP/MCP strip's
// layout math + key-span mapping + level fill directly.
//
// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area
// (band yields to the keymap minimum, no inversion), and a zero area (all empty);
// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly,
// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match
// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list;
// keySpanRect mapping the 128-key span linearly, tiling adjacent spans seamlessly,
// resolving a single-key span (the root marker), and clamping out-of-range/inverted notes;
// levelFillRect clamping 0..1 and its endpoints.
#include "../src/core/instrument/ui/embed_strip.h"
@@ -55,74 +54,48 @@ static void testLayoutZeroArea() {
CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y);
}
// --- zoneSegmentRect ----------------------------------------------------------
// --- keySpanRect --------------------------------------------------------------
static void testZoneSegmentFullSpan() {
// A zone covering the whole keyboard spans the entire keymap band width.
static void testKeySpanFullKeyboard() {
// The loaded capture responds across the whole keyboard, so its span is the whole band.
const EmbedLayout L = layoutEmbed(256, 40);
const Rect r = zoneSegmentRect(L, 0, 127);
const Rect r = keySpanRect(L, 0, 127);
CHECK(r.x == L.keymap.x);
CHECK(r.right() == L.keymap.right());
CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom());
}
static void testAdjacentZonesTileSeamlessly() {
// 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or
// overlap: the low zone's right == the high zone's left.
static void testAdjacentSpansTileSeamlessly() {
// 256px band, 128 keys -> 2px/key. Spans 0..59 and 60..127 must abut with no gap or
// overlap: the low span's right == the high span's left.
const EmbedLayout L = layoutEmbed(256, 40);
const Rect lo = zoneSegmentRect(L, 0, 59);
const Rect hi = zoneSegmentRect(L, 60, 127);
const Rect lo = keySpanRect(L, 0, 59);
const Rect hi = keySpanRect(L, 60, 127);
CHECK(lo.x == L.keymap.x);
CHECK(hi.right() == L.keymap.right());
CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion
CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px
}
static void testZoneSegmentClampsBadNotes() {
static void testSingleKeySpanIsTheRootMarker() {
// low == high is the root marker: exactly one key wide, inside the band.
const EmbedLayout L = layoutEmbed(256, 40);
// Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a
const Rect root = keySpanRect(L, 60, 60);
CHECK(root.x == L.keymap.x + 60 * 2);
CHECK(root.width == 2);
CHECK(root.y == L.keymap.y && root.bottom() == L.keymap.bottom());
}
static void testKeySpanClampsBadNotes() {
const EmbedLayout L = layoutEmbed(256, 40);
// Out-of-range notes clamp into the band; an inverted span (low > high) collapses to a
// zero-or-positive-width rect, never inverts.
const Rect over = zoneSegmentRect(L, -10, 200);
const Rect over = keySpanRect(L, -10, 200);
CHECK(over.x == L.keymap.x && over.right() == L.keymap.right());
const Rect inv = zoneSegmentRect(L, 100, 20);
const Rect inv = keySpanRect(L, 100, 20);
CHECK(inv.right() >= inv.x);
}
// --- zoneAtPoint --------------------------------------------------------------
static void testZoneAtPointHits() {
const EmbedLayout L = layoutEmbed(256, 40);
const EmbedZone zones[2] = {{0, 59}, {60, 127}};
// A point inside the low zone's segment resolves to zone 0; inside the high zone, 1.
const Rect lo = zoneSegmentRect(L, 0, 59);
const Rect hi = zoneSegmentRect(L, 60, 127);
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
CHECK(zoneAtPoint(L, zones, 2, lo.x + 1, yMid) == 0);
CHECK(zoneAtPoint(L, zones, 2, hi.right() - 1, yMid) == 1);
}
static void testZoneAtPointFirstMatchOnOverlap() {
const EmbedLayout L = layoutEmbed(256, 40);
// Two overlapping zones; the FIRST in order must win the contested keys.
const EmbedZone zones[2] = {{0, 127}, {40, 80}};
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
const Rect contested = zoneSegmentRect(L, 40, 80);
CHECK(zoneAtPoint(L, zones, 2, contested.x + 1, yMid) == 0); // zone 0 wins
}
static void testZoneAtPointMisses() {
const EmbedLayout L = layoutEmbed(256, 40);
const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
// A key left of the zone is uncovered -> -1.
CHECK(zoneAtPoint(L, zones, 1, L.keymap.x + 1, yMid) == -1);
// A point in the level band (below the keymap) is off the keymap -> -1.
CHECK(zoneAtPoint(L, zones, 1, L.levelBand.x + 4, L.levelBand.y) == -1);
// Empty / null list -> -1.
CHECK(zoneAtPoint(L, zones, 0, L.keymap.x + 1, yMid) == -1);
CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.x + 1, yMid) == -1);
}
// --- levelFillRect ------------------------------------------------------------
static void testLevelFillClamps() {
@@ -144,12 +117,10 @@ int main() {
testLayoutNormalArea();
testLayoutTinyAreaKeepsKeymap();
testLayoutZeroArea();
testZoneSegmentFullSpan();
testAdjacentZonesTileSeamlessly();
testZoneSegmentClampsBadNotes();
testZoneAtPointHits();
testZoneAtPointFirstMatchOnOverlap();
testZoneAtPointMisses();
testKeySpanFullKeyboard();
testAdjacentSpansTileSeamlessly();
testSingleKeySpanIsTheRootMarker();
testKeySpanClampsBadNotes();
testLevelFillClamps();
if (g_fail == 0) std::printf("embed_strip: all tests passed\n");
+3 -2
View File
@@ -116,7 +116,8 @@ static void testPresetRoundTripsThroughInstrumentReader() {
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId == id); // the capture IS selected — the whole point
CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones
// A drop selects one capture and leaves the parameter set at its defaults.
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign
}
@@ -158,7 +159,7 @@ static void testEmptyIdYieldsEmptyState() {
CHECK(!p.compChunk.empty()); // still a versioned envelope, just an empty selection
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId.empty());
CHECK(cs.map.zones.empty());
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
}
// Deterministic: the same id always produces the same bytes (no time/random in the path).
+4 -77
View File
@@ -1,15 +1,11 @@
// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's
// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta
// note resolver directly — the geometry that backs the single-capture root-set and the opt-in
// Zones panel.
// Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip
// layout, root marker, key mapping, and drag-delta note resolver directly — the geometry
// that backs the root display and root-set.
//
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning
// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt
// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the
// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding
// keyAtPoint inverting the mapping and clamping/ missing off-band; resolveDragNote rounding
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width
// no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with
// out-of-range inputs that clamp to [0,127].
@@ -95,69 +91,6 @@ static void testKeyAtPointOffBand() {
CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band
}
// --- zoneBarRect --------------------------------------------------------------
static void testZoneBarSpansInclusive() {
const StripLayout L = wideStrip();
const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive
CHECK(bar.x == keyLeftX(L, 12));
CHECK(bar.right() == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully
CHECK(bar.width == 120); // 12 keys * 10px
}
static void testZoneBarMalformedCollapses() {
const StripLayout L = wideStrip();
// low > high must collapse, never invert.
const Rect bar = zoneBarRect(L, 80, 40);
CHECK(bar.width >= 0);
CHECK(bar.right() >= bar.x);
}
// --- zoneGrabAt ---------------------------------------------------------------
static void testZoneGrabEdgesAndBody() {
const StripLayout L = wideStrip();
const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body
const int y = L.keys.y + 2;
// Near the left edge -> low; near the right edge -> high; the middle -> body.
CHECK(zoneGrabAt(L, 20, 60, bar.x + 1, y) == ZoneGrab::kLowEdge);
CHECK(zoneGrabAt(L, 20, 60, bar.right() - 1, y) == ZoneGrab::kHighEdge);
CHECK(zoneGrabAt(L, 20, 60, bar.x + bar.width / 2, y) == ZoneGrab::kBody);
// Off the bar entirely -> none.
CHECK(zoneGrabAt(L, 20, 60, bar.right() + 20, y) == ZoneGrab::kNone);
}
static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() {
const StripLayout L = wideStrip();
// A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint.
const Rect bar = zoneBarRect(L, 50, 50);
const int y = L.keys.y + 2;
const int mid = bar.x + bar.width / 2;
CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low
CHECK(zoneGrabAt(L, 50, 50, bar.right() - 1, y) == ZoneGrab::kHighEdge);
}
// --- zoneBarAtPoint -----------------------------------------------------------
static void testZoneBarAtPointFirstMatch() {
const StripLayout L = wideStrip();
const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50]
const int highs[2] = {50, 70};
const Rect overlap = zoneBarRect(L, 30, 50);
const int y = L.keys.y + 2;
const int cx = overlap.x + overlap.width / 2;
// A point in the overlap resolves to the FIRST covering zone (draw order).
const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y);
CHECK(hit.zoneIndex == 0);
CHECK(hit.grab != ZoneGrab::kNone);
}
static void testZoneBarAtPointNullList() {
const StripLayout L = wideStrip();
const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2);
CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone);
}
// --- resolveDragNote ----------------------------------------------------------
static void testResolveDragRoundsToNearestKey() {
@@ -246,12 +179,6 @@ int main() {
testRootMarkerEqualsKeyRect();
testKeyAtPointInverts();
testKeyAtPointOffBand();
testZoneBarSpansInclusive();
testZoneBarMalformedCollapses();
testZoneGrabEdgesAndBody();
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
testZoneBarAtPointFirstMatch();
testZoneBarAtPointNullList();
testResolveDragRoundsToNearestKey();
testResolveDragClampsAndNoOps();
testResolveDragProportionalNonDivisibleWidth();
-74
View File
@@ -1,74 +0,0 @@
// Standalone tests for reasampler::instrument::map::note_entry — no VST3, no REAPER, no framework.
// Assert the S12 direct-numeric-entry parse for a zone's low/high/root MIDI note.
//
// Covers: plain decimal integers (with +/- sign + surrounding whitespace); note names under the
// C4==60 convention (C-1==0, sharps + flats, negative octaves); out-of-range values CLAMPING to
// [0,127] rather than rejecting; empty / whitespace-only / unparseable input returning nullopt;
// the integer path taking precedence over the note-name path for a leading digit.
#include "../src/core/instrument/map/note_entry.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::map;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static void testPlainIntegers() {
CHECK(parseNoteEntry("60") == 60);
CHECK(parseNoteEntry("0") == 0);
CHECK(parseNoteEntry("127") == 127);
CHECK(parseNoteEntry(" 64 ") == 64); // surrounding whitespace ignored
CHECK(parseNoteEntry("+5") == 5);
}
static void testIntegerClamps() {
CHECK(parseNoteEntry("200") == 127); // over-range clamps to the ceiling
CHECK(parseNoteEntry("-10") == 0); // under-range clamps to the floor
CHECK(parseNoteEntry("99999") == 127);
}
static void testNoteNames() {
// C4 == 60 (MIDI 0 == C-1).
CHECK(parseNoteEntry("C4") == 60);
CHECK(parseNoteEntry("c4") == 60); // case-insensitive
CHECK(parseNoteEntry("A4") == 69); // A4 = 69 (concert A)
CHECK(parseNoteEntry("C-1") == 0); // lowest MIDI note
CHECK(parseNoteEntry("G9") == 127); // G9 = 127
}
static void testAccidentals() {
CHECK(parseNoteEntry("C#4") == 61);
CHECK(parseNoteEntry("Db4") == 61); // enharmonic of C#4
CHECK(parseNoteEntry("F#3") == 54);
CHECK(parseNoteEntry("Bb3") == 58); // Bb3 = 58
}
static void testNoteNameClamps() {
CHECK(parseNoteEntry("C10") == 127); // above the range clamps
CHECK(parseNoteEntry("C-5") == 0); // below the range clamps
}
static void testRejects() {
CHECK(parseNoteEntry("") == std::nullopt);
CHECK(parseNoteEntry(" ") == std::nullopt);
CHECK(parseNoteEntry("hello") == std::nullopt);
CHECK(parseNoteEntry("C") == std::nullopt); // a bare letter with no octave is ambiguous
CHECK(parseNoteEntry("H4") == std::nullopt); // H is not a note letter
CHECK(parseNoteEntry("+") == std::nullopt);
}
int main() {
testPlainIntegers();
testIntegerClamps();
testNoteNames();
testAccidentals();
testNoteNameClamps();
testRejects();
if (g_fail == 0) std::printf("note_entry: all tests passed\n");
return g_fail != 0;
}
+163
View File
@@ -0,0 +1,163 @@
// Standalone tests for reasampler::instrument::ui::sample_bands — no VST3, no REAPER, no
// test framework. Same fast assert loop as the sibling pure tests.
//
// Covers: the shared Rect vocabulary (contains() half-open + degenerate rects); the
// three-band vertical inventory (chrome over waveform over decks, no overlap, no
// inversion) asserted as pure geometry with no paint call; the waveform band's two-lane
// floor and the bands-clip-rather-than-squeeze rule on a short window; the deck band's
// bottom anchor and its exact requested height; and the lane split (mono = one full-band
// lane, stereo = two lanes with the seam gap between them).
#include "../src/core/instrument/ui/sample_bands.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- the shared Rect vocabulary ----------------------------------------------
static void testContainsHalfOpen() {
Rect r = Rect::ltrb(10, 20, 50, 40); // [10,50) x [20,40)
CHECK(contains(r, 10, 20)); // top-left inclusive
CHECK(contains(r, 49, 39)); // bottom-right exclusive edge, inside
CHECK(!contains(r, 50, 30)); // right edge excluded
CHECK(!contains(r, 30, 40)); // bottom edge excluded
CHECK(!contains(r, 9, 30)); // left of rect
CHECK(!contains(r, 30, 19)); // above rect
}
static void testContainsDegenerate() {
CHECK(!contains(Rect::ltrb(10, 10, 10, 20), 10, 15)); // zero width
CHECK(!contains(Rect::ltrb(10, 10, 20, 10), 15, 10)); // zero height
CHECK(!contains(Rect::ltrb(20, 10, 10, 20), 15, 15)); // inverted (right < left)
}
// --- the vertical inventory ---------------------------------------------------
static void testBandsStackTopToBottomWithoutOverlap() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.chrome.y == 0);
CHECK(b.chrome.height == kTitleHeight + kChromeRowHeight);
// Strictly ordered, no overlap: each band starts at or after the previous one's bottom.
CHECK(b.waveform.y >= b.chrome.bottom());
CHECK(b.decks.y >= b.waveform.bottom());
// No inversion anywhere.
CHECK(b.chrome.height > 0 && b.waveform.height > 0 && b.decks.height > 0);
CHECK(b.chrome.width > 0 && b.waveform.width > 0 && b.decks.width > 0);
}
static void testChromeSpansFullWidthAndLowerBandsAreInset() {
const SampleBands b = computeSampleBands(840, 620, 120);
CHECK(b.chrome.x == 0 && b.chrome.right() == 840);
CHECK(b.waveform.x == kPad && b.waveform.right() == 840 - kPad);
CHECK(b.decks.x == kPad && b.decks.right() == 840 - kPad);
}
static void testDeckBandIsBottomAnchoredAtItsRequestedHeight() {
const int deckH = 96;
const SampleBands b = computeSampleBands(840, 620, deckH);
CHECK(b.decks.height == deckH);
CHECK(b.decks.bottom() == 620 - kPad); // bottom-anchored inside the pad
}
static void testWaveformAbsorbsSlackAsTheWindowGrows() {
const SampleBands small = computeSampleBands(840, 620, 120);
const SampleBands big = computeSampleBands(840, 900, 120);
CHECK(big.waveform.height == small.waveform.height + 280);
// The fixed bands do not grow with the window.
CHECK(big.chrome.height == small.chrome.height);
CHECK(big.decks.height == small.decks.height);
}
static void testWaveformNeverShrinksBelowTheTwoLaneFloor() {
// A window far too short for chrome + two lanes + deck: the floor wins and the deck band
// is pushed past the bottom (clipped) rather than squeezing the waveform.
const SampleBands b = computeSampleBands(840, 160, 120);
CHECK(b.waveform.height == kWaveformMinHeight);
CHECK(b.decks.y >= b.waveform.bottom());
CHECK(b.decks.bottom() > 160); // deliberately clipped below the window
}
static void testTwoLaneFloorHoldsTwoUsableLanes() {
// The floor is exactly what two minimum lanes plus their seam need — not an arbitrary
// number, so a lane can never be allocated below its own minimum.
CHECK(kWaveformMinHeight == 2 * kLaneMinHeight + kLaneGap);
const SampleBands b = computeSampleBands(840, 160, 120);
const WaveformLanes lanes = waveformLanes(b.waveform, /*stereo=*/true);
CHECK(lanes.upper.height >= kLaneMinHeight);
CHECK(lanes.lower.height >= kLaneMinHeight);
}
static void testDegenerateWindowYieldsNoInvertedRects() {
const SampleBands z = computeSampleBands(0, 0, 0);
CHECK(z.chrome.width == 0 && z.chrome.height == 0);
CHECK(z.waveform.width <= 0 || z.waveform.height >= 0);
CHECK(z.waveform.right() >= z.waveform.x);
CHECK(z.decks.right() >= z.decks.x);
const SampleBands tiny = computeSampleBands(20, 20, 4);
CHECK(tiny.waveform.right() >= tiny.waveform.x);
CHECK(tiny.decks.right() >= tiny.decks.x);
}
// --- the waveform band's lanes ------------------------------------------------
static void testMonoUsesOneFullBandLane() {
const Rect band = Rect::ltrb(8, 100, 832, 300);
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/false);
CHECK(lanes.upper == band);
CHECK(lanes.lower.empty()); // no redundant duplicate lane in mono
}
static void testStereoSplitsIntoTwoLanesWithTheSeamGap() {
const Rect band = Rect::ltrb(8, 100, 832, 300); // height 200
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/true);
CHECK(lanes.upper.y == band.y);
CHECK(lanes.lower.bottom() == band.bottom());
// Full width each, seam exactly kLaneGap, no overlap.
CHECK(lanes.upper.x == band.x && lanes.upper.right() == band.right());
CHECK(lanes.lower.x == band.x && lanes.lower.right() == band.right());
CHECK(lanes.lower.y - lanes.upper.bottom() == kLaneGap);
CHECK(lanes.upper.height + lanes.lower.height + kLaneGap == band.height);
}
static void testStereoOddRemainderGoesToTheUpperLane() {
const Rect band = Rect::ltrb(0, 0, 100, 201); // usable 199 -> 100 / 99
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/true);
CHECK(lanes.upper.height == 100);
CHECK(lanes.lower.height == 99);
CHECK(lanes.lower.bottom() == band.bottom());
}
static void testEmptyBandYieldsEmptyLanes() {
const WaveformLanes lanes = waveformLanes(Rect{}, /*stereo=*/true);
CHECK(lanes.upper.empty());
CHECK(lanes.lower.empty());
}
int main() {
testContainsHalfOpen();
testContainsDegenerate();
testBandsStackTopToBottomWithoutOverlap();
testChromeSpansFullWidthAndLowerBandsAreInset();
testDeckBandIsBottomAnchoredAtItsRequestedHeight();
testWaveformAbsorbsSlackAsTheWindowGrows();
testWaveformNeverShrinksBelowTheTwoLaneFloor();
testTwoLaneFloorHoldsTwoUsableLanes();
testDegenerateWindowYieldsNoInvertedRects();
testMonoUsesOneFullBandLane();
testStereoSplitsIntoTwoLanesWithTheSeamGap();
testStereoOddRemainderGoesToTheUpperLane();
testEmptyBandYieldsEmptyLanes();
if (g_fail == 0) {
std::printf("sample_bands: all tests passed\n");
return 0;
}
std::printf("sample_bands: %d failure(s)\n", g_fail);
return 1;
}
+120
View File
@@ -0,0 +1,120 @@
// Standalone tests for reasampler::instrument::ui::sample_chrome — no VST3, no REAPER, no
// test framework.
//
// Covers: the chrome band's two rows (toolbar over control row, tiling the band exactly);
// the Browse button right-anchored inside the toolbar; the control row's fixed
// right-anchored run in order (preview, velocity cell, curve button, Mono|Stereo) with the
// root strip taking the remainder; the velocity knob centred in its cell above its label;
// and degenerate bands yielding no inverted rects.
#include "../src/core/instrument/ui/sample_bands.h"
#include "../src/core/instrument/ui/sample_chrome.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static constexpr int kKnob = 26; // stands in for knob_deck's kDeckKnobSize
static Rect chromeBand(int w = 840, int h = 620) {
return computeSampleBands(w, h, 120).chrome;
}
static void testRowsTileTheBandExactly() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
CHECK(r.toolbar.y == band.y);
CHECK(r.toolbar.height == kTitleHeight);
CHECK(r.controls.y == r.toolbar.bottom());
CHECK(r.controls.bottom() == band.bottom());
CHECK(r.toolbar.x == band.x && r.toolbar.right() == band.right());
CHECK(r.controls.x == band.x && r.controls.right() == band.right());
}
static void testBrowseIsRightAnchoredInsideTheToolbar() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
CHECK(r.navBrowse.right() == band.right() - kPad);
CHECK(r.navBrowse.width == kNavButtonWidth);
CHECK(r.navBrowse.y >= r.toolbar.y);
CHECK(r.navBrowse.bottom() <= r.toolbar.bottom());
}
static void testControlRunIsOrderedRightToLeftWithoutOverlap() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
// Rightmost first: stereo, mono, curve button, velocity cell, preview, then the strip.
CHECK(r.chanStereo.right() == band.right() - kPad);
CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.curveBtn.right() <= r.chanMono.x);
CHECK(r.velCell.right() <= r.curveBtn.x);
CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.rootStrip.right() <= r.preview.x);
CHECK(r.rootStrip.x == band.x + kPad);
CHECK(r.rootStrip.width > 0);
}
static void testRootStripTakesTheRemainderWidth() {
const ChromeRects narrow = chromeRects(chromeBand(600, 620), kKnob);
const ChromeRects wide = chromeRects(chromeBand(1000, 620), kKnob);
// The fixed run keeps its size; every extra pixel goes to the strip.
CHECK(wide.preview.width == narrow.preview.width);
CHECK(wide.velCell.width == narrow.velCell.width);
CHECK(wide.rootStrip.width == narrow.rootStrip.width + 400);
}
static void testVelocityKnobIsCentredInItsCellAboveTheLabel() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
CHECK(r.velKnob.width == kKnob && r.velKnob.height == kKnob);
CHECK(r.velKnob.y == r.velCell.y);
const int leftGap = r.velKnob.x - r.velCell.x;
const int rightGap = r.velCell.right() - r.velKnob.right();
CHECK(leftGap == rightGap); // horizontally centred in the cell
CHECK(r.velLabel.y == r.velKnob.bottom());
CHECK(r.velLabel.bottom() == r.velCell.bottom());
CHECK(r.velLabel.x == r.velCell.x && r.velLabel.right() == r.velCell.right());
}
static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects empty = chromeRects(Rect{}, kKnob);
CHECK(empty.toolbar.empty() && empty.controls.empty());
CHECK(empty.rootStrip.empty() && empty.preview.empty());
// A band far too narrow for the fixed run: the strip collapses, nothing inverts.
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
kKnob);
CHECK(tiny.rootStrip.right() >= tiny.rootStrip.x);
CHECK(tiny.navBrowse.right() >= tiny.navBrowse.x);
CHECK(tiny.preview.right() >= tiny.preview.x || tiny.preview.width < 0);
}
static void testToolbarOnlyBandStillPlacesTheNav() {
// A band clipped to just the toolbar row: the control row is empty but Browse still
// resolves, so the empty state's call-to-action is never unreachable.
const ChromeRects r = chromeRects(Rect::ltrb(0, 0, 400, kTitleHeight), kKnob);
CHECK(r.toolbar.height == kTitleHeight);
CHECK(r.controls.empty());
CHECK(r.navBrowse.width == kNavButtonWidth);
}
int main() {
testRowsTileTheBandExactly();
testBrowseIsRightAnchoredInsideTheToolbar();
testControlRunIsOrderedRightToLeftWithoutOverlap();
testRootStripTakesTheRemainderWidth();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testDegenerateBandYieldsNoInvertedRects();
testToolbarOnlyBandStillPlacesTheNav();
if (g_fail == 0) {
std::printf("sample_chrome: all tests passed\n");
return 0;
}
std::printf("sample_chrome: %d failure(s)\n", g_fail);
return 1;
}
+295 -2047
View File
File diff suppressed because it is too large Load Diff
+177 -187
View File
@@ -17,7 +17,7 @@
// by the CMake target linking neither SDK — this file includes only sampler_core.h +
// the standard library, which is itself the compile-time proof.
#include "../src/core/instrument/engine/sampler_core.h"
#include "../src/core/instrument/engine/voice_engine.h"
#include <algorithm>
#include <cmath>
@@ -69,49 +69,42 @@ static AdsrParams flatAdsr() {
}
// ---------------------------------------------------------------------------
// 6. Keymap resolution.
// 6. Full-keyboard response over the one loaded capture.
// ---------------------------------------------------------------------------
static void testChromaticSingleRoot() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
CHECK(km.zones.size() == 1);
// Every note in 0..127 resolves to the single zone.
static void testEveryKeyPlaysTheLoadedCapture() {
// No key range survives: the loaded capture answers every note in 0..127, repitched
// from its root. Each note-on must take a real voice.
SampleData km = dcSample(100, 60);
VoiceEngine eng(128, km);
for (int n = 0; n <= 127; ++n) {
ZoneResolution r = km.resolve(n, 100);
CHECK(r.matched);
CHECK(r.zoneIndex == 0);
CHECK(eng.noteOn(n, 100) != VoiceEngine::kNoVoice);
}
CHECK(eng.activeVoiceCount() == 128);
}
static void testZonedRangesBoundaries() {
Keymap km;
km.samples.push_back(dcSample(100, 48)); // low sample
km.samples.push_back(dcSample(100, 72)); // high sample
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
// correct zone; a first-match order test would catch an off-by-one.
km.zones.push_back(KeyZone{36, 59, 48, 0});
km.zones.push_back(KeyZone{60, 83, 72, 1});
static void testUnplayableCaptureRefusesEveryNote() {
// Nothing decoded -> the defined no-play at every key, in both voice modes, rather
// than a voice started on an empty read span.
SampleData empty; // no frames
VoiceEngine poly(4, empty);
CHECK(poly.noteOn(60, 100) == VoiceEngine::kNoVoice);
CHECK(poly.noteOn(0, 100) == VoiceEngine::kNoVoice);
CHECK(poly.activeVoiceCount() == 0);
CHECK(km.resolve(36, 100).matched);
CHECK(km.resolve(36, 100).zoneIndex == 0);
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0
CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1
CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1
// Out of every zone -> defined no-play (not a match, not zone 0).
CHECK(!km.resolve(35, 100).matched);
CHECK(!km.resolve(84, 100).matched);
CHECK(!km.resolve(127, 100).matched);
VoiceEngine mono(4, empty, 0, 0, VoiceMode::Mono);
CHECK(mono.noteOn(60, 100) == VoiceEngine::kNoVoice);
CHECK(mono.activeVoiceCount() == 0);
}
static void testFirstMatchOnOverlap() {
// Overlapping zones: the earlier zone wins (documented deterministic rule).
Keymap km;
km.samples.push_back(dcSample(10, 60));
km.samples.push_back(dcSample(10, 60));
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all
CHECK(km.resolve(60, 100).zoneIndex == 0);
static void testOutOfRangeNotesAreRefusedInMono() {
// The mono held stack keys notes as uint8, so an out-of-range note must be rejected
// BEFORE it can alias onto a real held note.
SampleData km = dcSample(100, 60);
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono);
CHECK(eng.noteOn(-1, 100) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(128, 100) == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0);
}
// ---------------------------------------------------------------------------
@@ -178,7 +171,7 @@ static void testRepitchObservedPeriod() {
// Unity: played at root, observed period ~= native.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -188,7 +181,7 @@ static void testRepitchObservedPeriod() {
}
// +1 octave: advances 2x, observed period halves.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(72, 127);
std::vector<AudioSample> out;
@@ -198,7 +191,7 @@ static void testRepitchObservedPeriod() {
}
// -1 octave: advances 0.5x, observed period doubles.
{
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
SampleData km = (sineSample(frames, cycles, 60));
VoiceEngine eng(4, km);
eng.noteOn(48, 127);
std::vector<AudioSample> out;
@@ -217,9 +210,9 @@ static void testKeyTrackVarispeedObservedPeriod() {
auto periodAt = [&](int note, double keyTrack) -> double {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
km.zones[0].keyTrack = keyTrack;
km.keyTrack = keyTrack;
VoiceEngine eng(4, km);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -247,8 +240,8 @@ static void testKeyTrackPreserveShiftCollapsesAtZero() {
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].keyTrack = keyTrack;
SampleData km = (std::move(s));
km.keyTrack = keyTrack;
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -375,7 +368,7 @@ static void testAdsrZeroAttackDecay() {
// ---------------------------------------------------------------------------
static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
SampleData km = (dcSample(1000, 60));
VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice.
@@ -423,11 +416,11 @@ static void testNoteOffReleasesNewestSameNote() {
SampleData sd = dcSample(100000, 60);
sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
Keymap km = Keymap::singleSampleChromatic(sd);
SampleData km = (sd);
// A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default
// flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this
// note-off-selection test relies on — so we opt this zone back to the linear response.
km.zones[0].velocityCurve = VelocityCurve::linear();
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
@@ -463,17 +456,6 @@ static void testNoteOffReleasesNewestSameNote() {
CHECK(eng.activeVoiceCount() == 0);
}
static void testOutOfZoneNoteConsumesNoVoice() {
Keymap km;
km.samples.push_back(dcSample(100, 60));
km.zones.push_back(KeyZone{60, 72, 60, 0});
VoiceEngine eng(4, km);
std::size_t v = eng.noteOn(30, 100); // below the only zone
CHECK(v == VoiceEngine::kNoVoice);
CHECK(eng.activeVoiceCount() == 0); // no voice consumed
}
// ---------------------------------------------------------------------------
// 2. Voice stealing at the bound.
// ---------------------------------------------------------------------------
@@ -484,7 +466,7 @@ static void testStealsReleasingVoiceFirst() {
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
@@ -509,7 +491,7 @@ static void testStealsOldestWhenNoneReleasing() {
SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
@@ -547,7 +529,7 @@ static void testLoopSustainSeamless() {
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -569,7 +551,7 @@ static void testZeroLengthLoopGoesSilent() {
s.loop.hasLoop = true;
s.loop.start = 25;
s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -593,7 +575,7 @@ static void testSingleFrameLoop() {
s.loop.start = 5;
s.loop.end = 6; // single-frame loop: [5, 6)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -612,7 +594,7 @@ static void testAbsentLoopGoesSilent() {
// No loop at all: held note runs off the end and goes idle (same as zero-length).
SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -633,7 +615,7 @@ static void testStartFrameOffsetsInitialRead() {
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
@@ -649,7 +631,7 @@ static void testStartFrameZeroIsUnchanged() {
s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -662,7 +644,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
// out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -683,7 +665,7 @@ static void testStartFrameWithLoop() {
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -712,7 +694,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity
@@ -734,11 +716,11 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
// velocity -> volume.
// ---------------------------------------------------------------------------
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone is now flat
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve is now flat
// y=1, so EVERY velocity plays at unity — NOT the old linear velocity/127. singleSampleChromatic
// builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
static void testVelocityDefaultCurveIsFlatUnity() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0, flat default curve
SampleData km = (dcSample(100, 60)); // DC 1.0, flat default curve
for (int vel : {1, 64, 100, 127}) {
VoiceEngine eng(1, km);
eng.noteOn(60, vel);
@@ -751,8 +733,8 @@ static void testVelocityDefaultCurveIsFlatUnity() {
// A LINEAR curve on the zone reproduces the pre-r10 velocity/127 ramp exactly — proving the curve
// (not a hardcoded map) drives the gain, and that eval is applied at note-on.
static void testVelocityLinearCurveReproducesRamp() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (dcSample(100, 60)); // DC 1.0
km.velocityCurve = VelocityCurve::linear();
{
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -776,10 +758,10 @@ static void testVelocityLinearCurveReproducesRamp() {
// A shaped curve (a single interior knot) drives the gain through eval — a mid velocity reads the
// curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
static void testVelocityShapedCurveDrivesGain() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
SampleData km = (dcSample(100, 60)); // DC 1.0
VelocityCurve curve = VelocityCurve::linear();
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
km.zones[0].velocityCurve = curve;
km.velocityCurve = curve;
VoiceEngine eng(1, km);
eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1);
@@ -790,7 +772,7 @@ static void testVelocityShapedCurveDrivesGain() {
// Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
SampleData km = (dcSample(100, 60)); // DC 1.0
VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
@@ -826,7 +808,7 @@ static void testChannelCount() {
static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
@@ -841,7 +823,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
SampleData km = (dcSample(100, 60)); // mono, DC 1.0
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
@@ -862,7 +844,7 @@ static void testDualMonoStereoSampleRendersCentered() {
SampleData s = sineSample(600, 12.0, 60);
s.framesR = s.frames; // dual-mono: identical channels
s.play.pitchEngine = engine;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
eng.noteOn(note, 127);
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
@@ -884,7 +866,7 @@ static void testDualMonoStereoSampleRendersCentered() {
static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
SampleData kmS = (stereoDcSample(100, 0.75f, -0.25f, 60));
VoiceEngine engS(1, kmS);
engS.noteOn(60, 127);
std::vector<AudioSample> mono;
@@ -909,7 +891,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
s.framesR[i] = v;
}
s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
@@ -920,7 +902,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
SampleData km = (stereoDcSample(100, 0.5f, -0.5f, 60));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note
@@ -931,7 +913,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
}
static void testStereoRenderNullBufferIsNoOp() {
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f);
@@ -960,7 +942,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
s.loop.start = 20;
s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
@@ -1058,7 +1040,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
SampleData km = (triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out;
@@ -1072,7 +1054,7 @@ static void testTriggerLengthFractionFrames() {
// --- Trigger start point: %-length measured from the start offset. ---
static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
SampleData km = (triggerSample(200, 0.5, 0, 0, /*start=*/40));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1086,7 +1068,7 @@ static void testTriggerLengthWithStart() {
static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
SampleData km = (triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1106,7 +1088,7 @@ static void testTriggerFadeShape() {
static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
SampleData km = (triggerSample(100, 0.0, 5, 5));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1117,7 +1099,7 @@ static void testTriggerEdgeCases() {
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
{
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
SampleData km = (triggerSample(40, 1.0, 30, 30));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1127,7 +1109,7 @@ static void testTriggerEdgeCases() {
}
// %=100 plays the full post-start span.
{
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
SampleData km = (triggerSample(60, 1.0, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1139,7 +1121,7 @@ static void testTriggerEdgeCases() {
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() {
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
SampleData km = (triggerSample(200, 0.5, 0, 0));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1189,7 +1171,7 @@ static void testPreserveDurationInvariance() {
const std::size_t window = 512; // pre-size the shifters
auto lengthAt = [&](int note) -> std::size_t {
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
SampleData km = (preserveTriggerSample(frames, 1.0));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
@@ -1216,7 +1198,7 @@ static void testVarispeedStillCouplesDuration() {
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(note, 127);
return soundingLength(eng, 4000);
@@ -1241,7 +1223,7 @@ static void testPitchEnvOffBitIdentical() {
s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500;
}
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out;
@@ -1269,7 +1251,7 @@ static void testPitchEnvOnBendsVarispeed() {
s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out;
@@ -1304,7 +1286,7 @@ static void testPreserveGateStereoLoopComposes() {
s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
@@ -1337,7 +1319,7 @@ static void testPreserveGateStereoLoopComposes() {
static void testPreserveVoiceCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
@@ -1362,7 +1344,7 @@ static void testPreserveUnityEngineVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -1394,7 +1376,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
std::vector<AudioSample> out;
@@ -1413,7 +1395,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
static void testPreserveUnityVoiceCountsTowardCap() {
SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
@@ -1429,8 +1411,8 @@ static void testVelocityCurveAppliesUnderPreserve() {
auto steadyLevelAt = [&](int vel) -> double {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
std::vector<AudioSample> out;
@@ -1460,7 +1442,7 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out;
@@ -1483,7 +1465,7 @@ static void testZeroAdsrIsInstantSustain() {
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1507,17 +1489,20 @@ static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
return s;
}
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
// notes [60,80] root 70 -> 0.75.
static Keymap twoLevelKeymap() {
Keymap km;
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
km.zones.push_back(a);
km.zones.push_back(b);
// The mono tests need to read WHICH NOTE holds the single voice off the rendered value, and
// a DC sample makes pitch inaudible. Velocity is the discriminator: a DC 1.0 capture with a
// curve pinned through two probe velocities renders 0.25 for a kVelLow strike and 0.75 for a
// kVelHigh one (the Hermite spline passes exactly through its control points). Each test
// then presses note 50 soft and note 70 hard, so the level names the sounding note.
static constexpr int kVelLow = 32;
static constexpr int kVelHigh = 96;
static SampleData twoLevelSample() {
SampleData km = dcLevelSample(200000, 1.0f, 60);
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
{static_cast<double>(kVelLow), 0.25},
{static_cast<double>(kVelHigh), 0.75},
{127.0, 1.0}});
return km;
}
@@ -1532,11 +1517,11 @@ static double probeFrame(VoiceEngine& eng) {
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
// ONE voice regardless of the pool size.
static void testMonoLastNotePriorityAndFallback() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
CHECK(eng.noteOn(50, kVelLow) == 0); // zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
CHECK(eng.noteOn(70, kVelHigh) == 0); // zone B TAKES the voice (last-note priority)
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // top released -> FALLBACK to still-held 50
@@ -1549,10 +1534,10 @@ static void testMonoLastNotePriorityAndFallback() {
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
// note also leaves the stack, so the final note-off truly empties it.
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
eng.noteOn(50, kVelLow);
eng.noteOn(70, kVelHigh); // 70 sounds, 50 held beneath
eng.noteOff(50); // releasing the buried note: inaudible
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
@@ -1562,11 +1547,11 @@ static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
// beneath becomes the fallback.
static void testMonoRepressHeldNoteMovesToTop() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127);
eng.noteOn(70, 127);
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
eng.noteOn(50, kVelLow);
eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(50, kVelLow) == 0); // re-press while held: back on top
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // falls back to 70 (now the most recent held)
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
@@ -1578,8 +1563,8 @@ static void testMonoRepressHeldNoteMovesToTop() {
// held note on the stack), not the departing note's.
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear(); // gain = velocity/127
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 32); // soft first note
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
@@ -1589,16 +1574,17 @@ static void testMonoRetriggerFallbackUsesOriginalVelocity() {
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
}
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
static void testMonoOutOfZoneNeverJoinsStack() {
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
// An OUT-OF-RANGE note in mono is a defined no-play: it consumes nothing, never joins the
// stack (so it can never take the voice back on a fallback), and its note-off is inert. The
// stack keys notes as uint8, so an unguarded 200 would alias onto 72 and corrupt it.
static void testMonoOutOfRangeNeverJoinsStack() {
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(70, 127);
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(200, 127) == VoiceEngine::kNoVoice); // past the MIDI range
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
eng.noteOff(20); // inert
eng.noteOff(200); // inert
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1609,7 +1595,7 @@ static void testMonoOutOfZoneNeverJoinsStack() {
static void testMonoRetriggerRestartsEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1623,7 +1609,7 @@ static void testMonoRetriggerRestartsEnvelope() {
static void testMonoLegatoContinuesEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1645,8 +1631,8 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
}
s.rootNote = 60;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear();
SampleData km = (std::move(s));
km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
std::vector<AudioSample> out;
@@ -1657,16 +1643,21 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
}
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
// sample restarts the voice (one read head cannot glide between two PCM streams).
static void testMonoLegatoCrossSampleRestarts() {
Keymap km = twoLevelKeymap();
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
// LEGATO takeover ALWAYS glides now: with one loaded capture there is no second PCM stream
// to cross into, so the read head never has to restart mid-phrase. (The retired
// cross-sample-restart branch was the multi-zone case.)
static void testMonoLegatoAlwaysGlidesWithinThePhrase() {
SampleData km = twoLevelSample();
km.play.adsr.attackFrames = 100; // a slow attack would expose any restart
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
eng.noteOn(50, kVelLow);
std::vector<AudioSample> out;
eng.render(out, 50); // mid-attack: level ~0.49 * the 0.25 vel gain
CHECK(approx(out[49], 0.49 * 0.25, 1e-6));
eng.noteOn(70, kVelHigh); // takeover: envelope KEEPS running, no re-attack
// Frame 50 of the SAME attack ramp, still at the FIRST strike's velocity gain (a legato
// phrase is one gesture, one strike) — NOT 0.0 (a restart) and NOT 0.75 (a re-strike).
CHECK(approx(probeFrame(eng), 0.50 * 0.25, 1e-6));
}
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
@@ -1675,7 +1666,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100;
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1692,7 +1683,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
static void testMonoIgnoresPreserveCap() {
SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
@@ -1719,7 +1710,7 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame
std::vector<AudioSample> out;
@@ -1739,7 +1730,7 @@ static void testMonoLegatoTriggerReattacksAfterKeyUp() {
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
@@ -1751,7 +1742,7 @@ static void testMonoLegatoTriggerHeldKeyStillRetunes() {
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
static void testAllNotesOffReleasesPolyVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
@@ -1765,16 +1756,16 @@ static void testAllNotesOffReleasesPolyVoices() {
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
static void testAllNotesOffClearsMonoHeldStack() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
eng.noteOn(50, kVelLow); // 50's note-off will never arrive (phantom)
eng.noteOn(70, kVelHigh); // 70 sounds, phantom 50 buried on the stack
eng.allNotesOff(); // PANIC
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0);
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
eng.noteOn(70, 127);
eng.noteOn(70, kVelHigh);
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1790,7 +1781,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
CHECK(eng.activeVoiceCount() == 1);
@@ -1812,7 +1803,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
static void testAllNotesOffStillReleasesGateVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
// Default Gate mode, instant release (releaseFrames 0).
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(4, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127);
@@ -1829,7 +1820,7 @@ static void testAllNotesOffStillReleasesGateVoices() {
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
static void testMonoLegatoSameNoteRepressReattacks() {
SampleData s = rampSample(200000, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // first press; read starts at 0
std::vector<AudioSample> out;
@@ -1845,7 +1836,7 @@ static void testMonoLegatoSameNoteRepressReattacks() {
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
static void testMonoOutOfRangeNotesRejected() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
@@ -1865,7 +1856,7 @@ static void testMonoOutOfRangeNotesRejected() {
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
static void testVoiceCountBoundsPolyphony() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine e3(3, km);
CHECK(e3.maxVoices() == 3);
e3.noteOn(60, 127);
@@ -1894,7 +1885,7 @@ static void testMonoRetrigTakeoverDeclicksRestart() {
s.play.adsr.attackFrames = 100; // real attack: the new tone starts near 0
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1930,7 +1921,7 @@ static void testMonoRetrigFallbackDeclicksRestart() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1965,7 +1956,7 @@ static void testMonoDeclickOnlyOnTakeover() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -1993,7 +1984,7 @@ static void testPolyStealDeclicksRestart() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2026,7 +2017,7 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2062,7 +2053,7 @@ static void testZeroAttackTakeoverNeverExceedsFullScale() {
s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2107,7 +2098,7 @@ static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& pos
static void testMonoRetrigTriggerZoneDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame
s.play.playMode = PlayMode::Trigger; // default fades: NO fade-in -> amp 1 at frame 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2134,7 +2125,7 @@ static void testZeroAttackGateRetrigNoStep() {
s.play.adsr.attackFrames = 0; // instant-unity attack
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2158,7 +2149,7 @@ static void testZeroAttackGateRetrigNoStep() {
// preview's exact shape (same note, root, full pool of 1).
static void testPreviewReauditionDeclicksViaEngineSteal() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2186,7 +2177,7 @@ static void testOverCapChordStealsExactlyOne() {
s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
// Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400);
@@ -2238,7 +2229,7 @@ static void testOverCapChordStealsExactlyOne() {
// path. This is the processor's mailbox-drain contract, pinned in the pure core.
static void testPreviewNoteObeysVoicing() {
SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(2, km);
eng.noteOn(60, 127);
eng.noteOn(62, 127); // the pool is now FULL
@@ -2260,11 +2251,11 @@ static void testPreviewNoteObeysVoicing() {
// Pins the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
// pins it for Poly steal.
static void testPreviewNoteJoinsMonoHeldStack() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(50, 127) == 0); // the host-MIDI note: zone A sounds
CHECK(eng.noteOn(50, kVelLow) == 0); // the host-MIDI note: zone A sounds
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
CHECK(eng.noteOn(70, 127) == 0); // the preview press: TAKES the voice
CHECK(eng.noteOn(70, kVelHigh) == 0); // the preview press: TAKES the voice
CHECK(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // preview release: FALLBACK to the held note
@@ -2281,10 +2272,10 @@ static void testPreviewNoteJoinsMonoHeldStack() {
// the drain engine must release its voice (otherwise the old-snapshot preview would
// sustain until the next reload hard-cut it).
static void testPreviewNoteOffRoutesToDrainEngine() {
Keymap km = twoLevelKeymap();
SampleData km = twoLevelSample();
VoiceEngine drainEng(2, km); // was live when the preview fired
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices
CHECK(drainEng.noteOn(70, 127) != VoiceEngine::kNoVoice);
CHECK(drainEng.noteOn(70, kVelHigh) != VoiceEngine::kNoVoice);
CHECK(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
CHECK(liveEng.activeVoiceCount() == 0);
// The preview release, drained to BOTH engines like a host note-off:
@@ -2313,7 +2304,7 @@ static void testDeclickBoundedBlendNoOvershoot() {
const double kCycles = 6000.0; // period = 8 frames
SampleData s = sineSample(kFrames, kCycles, 60);
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true);
@@ -2402,7 +2393,7 @@ static void testPreserveTailFinalWindowGapFree() {
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end
s.play.adsr = flatAdsr(); // held: amp 1 to the end (isolates the DSP)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(note, 127);
std::vector<AudioSample> out;
@@ -2430,7 +2421,7 @@ static void testPreserveTailReleaseContinuous() {
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = static_cast<std::int64_t>(w); // release spans the final window
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
std::vector<AudioSample> out;
@@ -2462,7 +2453,7 @@ static void testPreserveTriggerTailGapFree() {
s.play.pitchEngine = PitchEngine::Preserve;
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero)
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127);
const std::size_t playEnd = 6554; // round(0.8 * 8192)
@@ -2497,7 +2488,7 @@ static void testPreservePrimeStopsAtTriggerPlayEnd() {
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
// the deepest primed history the ring holds
@@ -2525,7 +2516,7 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s));
SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
std::vector<AudioSample> out;
@@ -2538,9 +2529,9 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
}
int main() {
testChromaticSingleRoot();
testZonedRangesBoundaries();
testFirstMatchOnOverlap();
testEveryKeyPlaysTheLoadedCapture();
testUnplayableCaptureRefusesEveryNote();
testOutOfRangeNotesAreRefusedInMono();
testPitchRatioMath();
testKeyTrackedRatioMath();
testRepitchObservedPeriod();
@@ -2551,7 +2542,6 @@ int main() {
testAdsrZeroAttackDecay();
testPolyphonicAllocation();
testNoteOffReleasesNewestSameNote();
testOutOfZoneNoteConsumesNoVoice();
testStealsReleasingVoiceFirst();
testStealsOldestWhenNoneReleasing();
testLoopSustainSeamless();
@@ -2611,11 +2601,11 @@ int main() {
testMonoReleaseOfLowerHeldNoteIsInaudible();
testMonoRepressHeldNoteMovesToTop();
testMonoRetriggerFallbackUsesOriginalVelocity();
testMonoOutOfZoneNeverJoinsStack();
testMonoOutOfRangeNeverJoinsStack();
testMonoRetriggerRestartsEnvelope();
testMonoLegatoContinuesEnvelope();
testMonoLegatoRetunesWithoutReadRestart();
testMonoLegatoCrossSampleRestarts();
testMonoLegatoAlwaysGlidesWithinThePhrase();
testMonoLegatoAfterReleaseReattacks();
testMonoIgnoresPreserveCap();
testMonoLegatoTriggerReattacksAfterKeyUp();