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 # 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 # MIDI-playback instrument (D3): polyphonic voice allocation with bounded stealing,
# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with # the three envelope evaluators, and repitch/interpolation from a root note with
# (note, velocity) -> zone resolution, and repitch/interpolation from a root note # loop-point-aware sustain over ONE loaded capture. The mirror of bank_model / peaks /
# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book, # bank_book, tested hard outside any host. Lives under core/instrument/engine/ but
# tested hard outside any host. Lives under core/instrument/engine/ but
# links NEITHER SDK — the plain-data boundary is enforced structurally: the test # 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, # 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/ # 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 # 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 # 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 # 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 # dependency on the editor's layout types. play_params.h carries one (SampleData holds the
# VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine. # 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) add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp)
target_include_directories(velocity_curve PUBLIC src) 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_include_directories(sampler_core PUBLIC src)
target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve) 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. # 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 # editor_geometry: the shared geometry vocabulary every instrument UI module speaks
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge # (the `Rect` alias + contains()) — header-only, hence INTERFACE; the Sample face's
# read marshalling — GetProjExtState result decode + the ONE grow-loop retry # own layout lives in sample_bands/sample_chrome below. bridge_marshal: the REAPER
# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge # VST-host bridge read marshalling — GetProjExtState result decode + the ONE
# read AND the extension's persist/usage_scan ext-state reads (hence linked into # grow-loop retry policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the
# reaper_reasampler too). Both are unit-tested outside the DAW; # VST bridge read AND the extension's persist/usage_scan ext-state reads (hence
# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified. # 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) add_library(editor_geometry INTERFACE)
target_include_directories(editor_geometry PUBLIC src) 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) add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp)
target_include_directories(bridge_marshal PUBLIC src) target_include_directories(bridge_marshal PUBLIC src)
# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the # embed_strip (Phase S6) — PURE layout for the embedded TCP/MCP strip: the 128-key span ->
# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill. # key-span rects (the loaded capture's full span, and its root marker) and the level-band
# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside # fill. Read-only, so no hit-test. Unit-tested outside the DAW, while the embed shell
# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed # (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed messages (the paint
# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect. # bitmap) into it. Links editor_geometry for the shared Rect.
add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp) add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp)
target_include_directories(embed_strip PUBLIC src) target_include_directories(embed_strip PUBLIC src)
target_link_libraries(embed_strip PUBLIC editor_geometry) target_link_libraries(embed_strip PUBLIC editor_geometry)
# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the # 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, # 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 # NOT a second parser), interleaved->mono downmix (the channel policy), the one parameter
# keymap builds, and the refs/performance resolution. Links the three pure modules it # 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), and sampler_core (the # composes — bank_book (shared JSON), wav_codec (shared WAV parse), velocity_curve (the
# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor) # curve field play_params.h carries) — and NEITHER SDK. NOT the voice engine: since the
# does the bridge read + file I/O off the audio thread, then calls these; the process # build's product is plain SampleData, the engine's object code is no longer a dependency.
# callback stays allocation-free. The ComponentState codec is component_state_io below. # 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) add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp)
target_include_directories(sample_map PUBLIC src) 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 # 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 # ENVELOPE + params-payload binary codec (envelope v1..v11, params payload v1..v8, every
# lift preserved byte-identically). Split so the codec — which grows on every envelope # lift preserved byte-identically; v1..v7 are the retired zone lists, read via the
# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links # adopt-zone-one migration). Split so the codec — which grows on every envelope bump and is
# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and # shared with the EXTENSION's preset-blob path (instrument_drop) — links WITHOUT the voice
# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters # engine: its deps are velocity_curve (the curve field) and master_gain (the v8 wire cap)
# the extension binary. Its own test target linking exactly these is the structural proof. # 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) add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp)
target_include_directories(component_state_io PUBLIC src) target_include_directories(component_state_io PUBLIC src)
target_link_libraries(component_state_io PUBLIC velocity_curve master_gain) 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_include_directories(capture_browser PUBLIC src)
target_link_libraries(capture_browser PUBLIC editor_geometry) target_link_libraries(capture_browser PUBLIC editor_geometry)
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects + # keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, and the
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's # drag-delta note resolver for the editor's keyboard strip (root display + root-set). The
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of # mirror of embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp) add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp)
target_include_directories(keyboard_strip PUBLIC src) target_include_directories(keyboard_strip PUBLIC src)
target_link_libraries(keyboard_strip PUBLIC editor_geometry) 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. # metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp) add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp)
target_include_directories(browser_scroll PUBLIC src) target_include_directories(browser_scroll PUBLIC src)
target_link_libraries(browser_scroll PUBLIC capture_browser) target_link_libraries(browser_scroll PUBLIC capture_browser sample_chrome)
# 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)
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface # 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 # 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 # 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. # 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. # 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 # Deliberately engine-free (no sample_map / sampler_core) — the shell packs the one parameter
# AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK. # 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) add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp)
target_include_directories(envelope_overlay PUBLIC src) target_include_directories(envelope_overlay PUBLIC src)
target_link_libraries(envelope_overlay PUBLIC editor_geometry) 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) add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
target_include_directories(master_gain PUBLIC src) target_include_directories(master_gain PUBLIC src)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) # sample_bands: the band-stack allocator's vertical inventory, asserted as pure geometry
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) # (chrome / two-lane waveform / deck row) independent of any paint call — the contract the
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) # 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) add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp)
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) 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) target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests) 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 # param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
# value<->pixel mapping the editor parameter surface draws + routes against. # value<->pixel mapping the editor parameter surface draws + routes against.
add_executable(param_slider_tests tests/test_param_slider.cpp) 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/reasampler_processor.cpp
src/shell/instrument/processor_state.cpp src/shell/instrument/processor_state.cpp
src/shell/instrument/processor_reload.cpp src/shell/instrument/processor_reload.cpp
# The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state, # The editor family, split on the Sample face's BAND axis: session/bridge state,
# param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform; # param plumbing + the shared band-layout resolve, then paint and input in matching
# the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry # sets — dispatch, chrome, waveform, decks — plus the two band-independent surfaces
# + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU). # (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_session.cpp
src/shell/instrument/editor_controls.cpp src/shell/instrument/editor_controls.cpp
src/shell/instrument/editor_paint_sample.cpp src/shell/instrument/editor_paint.cpp
src/shell/instrument/editor_paint_browse_zone.cpp src/shell/instrument/editor_paint_chrome.cpp
src/shell/instrument/editor_input_sample.cpp src/shell/instrument/editor_paint_waveform.cpp
src/shell/instrument/editor_input_browse_zone.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/editor_platform.cpp
src/shell/instrument/reasampler_embed.cpp src/shell/instrument/reasampler_embed.cpp
src/shell/instrument/reaper_bridge.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} ${LICE_SRC}
) )
# editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure # 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; # bank -> one-capture resolve + SampleData build the processor drives off the audio
# linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model, # thread; linking it pulls its pure deps (bank_book, wav_codec, velocity_curve, peaks)
# peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / # transitively — deliberately NOT sampler_core (the voice engine). capture_paths: the
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src) # shared M4 path resolution (resolveBankFile / projectDirOfRpp) the bridge + processor
# gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). # 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 # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals
# into; it links editor_geometry transitively (shared Rect). # into; it links editor_geometry transitively (shared Rect).
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so # 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 # the instrument reads the SAME namespace the extension writes; its PUBLIC include dir
# (build/generated) carries version_generated.h for the channel bit. # (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 # 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 # geometry the capture-first editor draws + hit-tests against; both link editor_geometry
# transitively (shared Rect). # 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 # 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 # 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. # 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 # browser_scroll + param_slider (S12 + S15/S16 control surfaces): the pure scroll/search
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the # geometry over the capture browser, and the control-panel layout + slider/toggle
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface # value<->pixel mapping the editor's parameter surface draws + routes against.
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider + # browser_scroll pulls capture_browser transitively; param_slider links editor_geometry /
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell. # 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 # 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 # 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 # 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 # sample_usage (pS-usage): the usage-record wire + publish plan the processor's
# reloadInstrument publishes through the bridge (the one sanctioned VST-side write). # reloadInstrument publishes through the bridge (the one sanctioned VST-side write).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal 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 sampler_core sample_map component_state_io capture_paths embed_strip app_version
waveform_view bank_sync browser_scroll note_entry param_slider 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 theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck curve_popup master_gain sample_usage file_bytes) knob_deck curve_popup master_gain sample_usage file_bytes)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # 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 The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
subdirectories: 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. velocity curve, and master-gain taper math.
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the - **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
small pure helpers the engine/shell share (bank-generation sync, bridge-read `ComponentState` codec, and the small pure helpers the engine/shell share
marshalling, note-name parsing, Trigger frame↔fraction conversion). (bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip, frame↔fraction conversion).
capture browser, param controls, envelope overlay/edit). These are geometry-and-math - **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell, interiors, waveform, keyboard strip, capture browser, param controls, envelope
**out of scope for this file** (owned by a parallel dispatch), along with the VST3 overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`. the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
`reasampler_embed`, and `vst_entry`.
## Invariants ## Invariants
@@ -39,6 +40,21 @@ subdirectories:
audio — the bank index, the mapping, which project is active — the instrument reads audio — the bank index, the mapping, which project is active — the instrument reads
the live `"reasampler"` ext-state via the bridge. 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 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: 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 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 notes) are facts about the file, added as an additive field extension (same shape as
`provenance`). `provenance`).
- **The performance map (a creative arrangement) lives in the instrument.** Key zones, - **Performance choices live in the instrument.** Amplitude envelopes and per-sample
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain tuning/gain trim are a performance choice, not a fact about a file — they belong to the
trim are a performance choice, not a fact about a file — they belong to the instrument, instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
not the bank. This "who owns which field" rule (D-B) governs every performance-map added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve per-instance `ComponentState`, never written to `Sample` or the bank.
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
the bank.
### The pure core (D3 — the load-bearing split) ### The pure core (D3 — the load-bearing split)
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation, The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE `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 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. 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). held/out of scope (fork S15-F1).
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤ - **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none. 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 classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
interp — resampling that couples pitch and duration; cheap, zero-latency, musically interp — resampling that couples pitch and duration; cheap, zero-latency, musically
right for drums/one-shots. Preserve (duration-preserving): the read advances at the 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 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 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 (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, one model: both read/write the same envelope fields of the one parameter set, so a drag
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth, updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
node cannot be dragged past its neighbours) and range-clamped to the same per-param monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't. same per-param min/max the sliders enforce, so node-drag can never produce a param the
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay` slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
and `envelope_edit` in Modules below. `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). (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 velocity** — a per-instance utility setting for the Sample view's
preview-trigger button (not a musical parameter of the capture); **persists across preview-trigger button (not a musical parameter of the capture); **persists across
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the 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 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). 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 - **Velocity curve** — 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 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 — 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). a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
@@ -183,25 +197,29 @@ and `envelope_edit` in Modules below.
### `engine/` ### `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. - 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:
- `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. - `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()`. - `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). - `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. - `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/` ### `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. - `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 + 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. - `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. - `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. - `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. - `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/` ### `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. - `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.
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry. - `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. - `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. - `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. - `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. - **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. - **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 - **Two superseded designs are called out in Invariants above**: the earlier
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency 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 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 #pragma once
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by // play_params.h — the instrument's one set of playback-parameter value structs plus the
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h // per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member // the editor. Split out of the engine headers so a UI/codec TU reading a param struct
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the // doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h. // in envelopes.h; the engine in voice.h / voice_engine.h.
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/audio/peaks.h" #include "core/audio/peaks.h"
#include "core/instrument/engine/velocity_curve.h"
namespace reasampler { namespace reasampler {
using audio::AudioSample; using audio::AudioSample;
using instrument::engine::VelocityCurve;
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently // 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. // 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 // 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 // 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 // re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
enum class MonoTrigger { Retrigger, Legato }; enum class MonoTrigger { Retrigger, Legato };
// Shared range so the engine, the component-state codec, and the editor control can't drift. // 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: // 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 // 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 // honor the start point. Default Gate so an instrument with no params set plays as before.
// exactly as before.
enum class PlayMode { Gate, Trigger }; enum class PlayMode { Gate, Trigger };
// Playback covers [startFrame, playEnd), playEnd = startFrame + // Playback covers [startFrame, playEnd), playEnd = startFrame +
@@ -69,10 +69,10 @@ inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// (an octave up keeps its length). // (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve }; enum class PitchEngine { Varispeed, Preserve };
// Product default is Preserve, but applied at the state boundary (sample_map deserialize / // Product default is Preserve, but applied at the state boundary (the codec's read path /
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself // the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression // so "no params == the bare engine" holds for the core's own regression tests (an octave up
// tests (an octave up still halves duration with no params set). // still halves duration with no params set).
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother // 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, // 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 // Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// default is layered on at (de)serialization, see kDefaultPitchEngine. // default is layered on at (de)serialization, see kDefaultPitchEngine.
struct ZonePlayParams { struct PlayParams {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; AdsrParams adsr;
TriggerParams trigger; TriggerParams trigger;
@@ -101,9 +101,6 @@ struct ZonePlayParams {
PitchEnvParams pitchEnv; 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" // [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. // marker — a held note past the sample end goes silent rather than looping a zero span.
struct SampleLoop { struct SampleLoop {
@@ -112,11 +109,14 @@ struct SampleLoop {
std::int64_t end = 0; 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 // 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 // (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 // `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 share the read head / rootNote / loop, so repitch and loop stay per-frame identical
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there. // across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
struct SampleData { struct SampleData {
std::vector<AudioSample> frames; std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample 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). // Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
std::int64_t startFrame = 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 // A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays. // never half-plays.
int channelCount() const { int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; 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 } // 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
+208 -207
View File
@@ -1,5 +1,5 @@
// component_state_io — the ComponentState envelope + zones-payload binary codec. See // component_state_io — the ComponentState envelope + params-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7). // component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
// Every wire format is FROZEN — byte-identical across revisions. // Every wire format is FROZEN — byte-identical across revisions.
#include "core/instrument/map/component_state_io.h" #include "core/instrument/map/component_state_io.h"
@@ -11,7 +11,7 @@
#include <utility> // std::move #include <utility> // std::move
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap #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 { namespace reasampler::instrument::map {
@@ -26,39 +26,42 @@ namespace {
// Signed 64-bit values ride the wire as their two's-complement unsigned image. // 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); } 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 // What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
// blob, so both write zones identically. Always emits the CURRENT payload version (marker + // payload that carried at least one zone: the first zone's capture, which supersedes the
// version + extended records: loop/start tail + full play-params tail in SECONDS); the // envelope's selection id (see the adoption rule in the header).
// marker precedes the zone count so any reader can detect record shape independent of the struct PayloadRead {
// envelope version (see sample_map.h). InstrumentParams params;
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) { std::string adoptedSampleId;
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));
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles); // Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
// trigger %-length + fades stay source frames/fraction. Order matches the header's // the two shapes cannot drift byte-for-byte.
// v5 record spec. void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
const ZonePlaySeconds& pp = z.play; 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); out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
@@ -74,50 +77,83 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out, doubleToBits(pp.adsr.decaySeconds)); putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel)); putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds)); putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET). // Key-tracking scalar (1.0 = 100% ET).
putLE(out, doubleToBits(z.keyTrack)); putLE(out, doubleToBits(p.keyTrack));
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE // The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
// control-point count, then per point velocity + amp as doubles (endpoints included). // per point velocity + amp as doubles (endpoints included, so N >= 2).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points(); const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size())); putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) { for (const VelocityPoint& pt : pts) {
putLE(out, doubleToBits(p.velocity)); putLE(out, doubleToBits(pt.velocity));
putLE(out, doubleToBits(p.amp)); putLE(out, doubleToBits(pt.amp));
}
} }
} }
// Read a zones payload from `r` into `map`. Shared by the performance parse and the // Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with // v8 single-record reader so the two can never disagree about field order.
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
// that parsed cleanly and drops the rest. p.play.adsr.holdSeconds = bitsToDouble(r.u64());
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock p.play.trigger.lengthFraction = bitsToDouble(r.u64());
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames p.play.trigger.fadeInFrames = r.i64();
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed. p.play.trigger.fadeOutFrames = r.i64();
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
bool extended = false; // v2+: the loop/start tail is present p.play.pitchEnv.enabled = (r.u8() != 0);
std::uint32_t pv = 0; // payload version (0 = v1, no marker) p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
if (r.peekU32() == kZonesFormatMarker) { p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
r.u32(); // consume the marker p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
pv = r.u32(); // payload version p.play.adsr.attackSeconds = bitsToDouble(r.u64());
extended = (pv >= 2); // v2+ carries the loop/start tail 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 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 secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
const std::uint32_t count = r.u32(); const std::uint32_t count = r.u32();
bool adopted = false;
for (std::uint32_t i = 0; i < count && r.ok; ++i) { 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 to the product defaults (Gate + Preserve +
// A v1/v2 payload (no play tail) lifts every zone to those defaults. // tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
PerformanceZone z; InstrumentParams p;
std::string sampleId;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen); sampleId = r.str(idLen);
z.lowNote = r.i32(); r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
z.highNote = r.i32(); r.i32(); // highNote
const std::uint8_t hasOverride = r.u8(); const std::uint8_t hasOverride = r.u8();
if (hasOverride) z.rootOverride = r.i32(); if (hasOverride) p.rootOverride = r.i32();
if (extended) { if (extended) {
const std::uint8_t hasLoop = r.u8(); const std::uint8_t hasLoop = r.u8();
if (hasLoop) { if (hasLoop) {
@@ -125,113 +161,88 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
lp.hasLoop = (r.u8() != 0); lp.hasLoop = (r.u8() != 0);
lp.start = r.i64(); lp.start = r.i64();
lp.end = r.i64(); lp.end = r.i64();
z.loopOverride = lp; p.loopOverride = lp;
} }
const std::uint8_t hasStart = r.u8(); const std::uint8_t hasStart = r.u8();
if (hasStart) z.startPoint = r.i64(); if (hasStart) p.startPoint = r.i64();
} }
if (legacyV3Play) { if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv // LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
// A/D) were written as frames -> divide by `projectRate` to reach seconds. // frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
// Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT // are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
// in v3 -> leave the seconds defaults on z.play.adsr. assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate; p.play.trigger.lengthFraction = bitsToDouble(r.u64());
z.play.trigger.lengthFraction = bitsToDouble(r.u64()); p.play.trigger.fadeInFrames = r.i64();
z.play.trigger.fadeInFrames = r.i64(); p.play.trigger.fadeOutFrames = r.i64();
z.play.trigger.fadeOutFrames = r.i64(); p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed; p.play.pitchEnv.enabled = (r.u8() != 0);
z.play.pitchEnv.enabled = (r.u8() != 0); p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate; p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate; p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
} else if (secondsPlay) { } else if (secondsPlay) {
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source readSecondsPlayTail(r, p);
// 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());
} }
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload // A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an // repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
// already-saved instance repitches BIT-IDENTICALLY. if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); if (curveTail) readCurveTail(r, p);
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7 // Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(), // leave the seconds product defaults on p.play.
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated if (!adopted) {
// read leaves the flat default and the mid-zone break below drops the rest. out.params = std::move(p);
if (curveTail) { out.adoptedSampleId = std::move(sampleId);
const std::uint32_t ptCount = r.u32(); adopted = true;
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));
} }
// 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; return out;
} }
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes, // Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
double projectRate) { // zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate. std::uint32_t pv = 0; // 0 = v1, no marker
PerformanceMap map; if (r.peekU32() == kParamsFormatMarker) {
ByteReader r(bytes); r.u32(); // consume the marker
const std::uint32_t version = r.u32(); pv = r.u32(); // payload version
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; if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
}
if (version != kPerformanceStateVersion) return map; // unknown -> empty
readZonesPayload(r, map, projectRate); PayloadRead out;
return map; 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 -------------------------------------- // --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& 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). // 1 = user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0); out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up // 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), // + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
// channelCount, displayName (length-prefixed; display-only). // channelCount, displayName (length-prefixed; display-only).
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size())); 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())); putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
out.insert(out.end(), e.displayName.begin(), e.displayName.end()); 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 // 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. // here (see the v10 lift). Empty = never published — legal, round-trips as empty.
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size())); putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end()); 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). // unlike the v1 selection blob where the id ran to end-of-stream).
putLE(out, static_cast<std::uint32_t>(state.selectionId.size())); putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
putZonesPayload(out, state.map); putParamsPayload(out, state.params);
return out; return out;
} }
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes, ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) { double projectRate) {
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for // projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
// v5+. See readZonesPayload for the guard.
ComponentState out; ComponentState out;
ByteReader r(bytes); ByteReader r(bytes);
const std::uint32_t version = r.u32(); const std::uint32_t version = r.u32();
if (!r.ok) return out; // no version tag -> empty (the silent empty state) if (!r.ok) return out; // no version tag -> empty (the silent empty state)
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split. // BACK-COMPAT: an older blob predates the v3 {selection, params} split.
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one // * v1 (original single-selection: version 1 + id-to-end): restore the id as the
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map. // loaded capture with default parameters.
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate // * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
// single-capture selection.
if (version == kSelectionStateVersion) { if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes); 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; return out;
} }
if (version == kPerformanceStateVersion) { if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
return out; // channelMode stays Mono return out; // channelMode stays Mono
} }
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id // BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
// length + id + zones body starts right after the version tag (no mode byte). // length + id + payload starts right after the version tag (no mode byte).
if (version == kSelectionZonesV3Version) { if (version == kSelectionV3Version) {
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty 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 return out; // channelMode stays Mono, marker stays 0
} }
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then // BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so // the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
// a first assign still applies for a pre-marker instance. // a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) { if (version == kSelectionModeV4Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty 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 return out; // marker stays 0
} }
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte): // BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an // previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
// already-saved instance restores at the mid default. // already-saved instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) { if (version == kSelectionModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; 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(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty 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 return out; // previewVelocity stays at the mid default
} }
if (version != kComponentStateVersion && if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version && version != kSelectionRefsV10Version &&
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
version != kSelectionZonesModeMarkerVelVoiceGainV8Version && version != kSelectionModeMarkerVelVoiceGainV8Version &&
version != kSelectionZonesModeMarkerVelVoiceV7Version && version != kSelectionModeMarkerVelVoiceV7Version &&
version != kSelectionZonesModeMarkerVelV6Version) { version != kSelectionModeMarkerVelV6Version) {
return out; // unknown -> empty return out; // unknown -> empty
} }
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte // v6..v11 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 // preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
// (conservative default) rather than rejected — a corrupt mode never silences the instance. // than rejected — a corrupt mode never silences the instance.
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
@@ -392,7 +393,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
: kPreviewVelocityDefault; : kPreviewVelocityDefault;
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults // v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior. // {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 vc = r.u8();
const std::uint8_t vm = r.u8(); const std::uint8_t vm = r.u8();
const std::uint8_t mt = 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 // 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 // (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
// than silencing/blasting. // than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64()); const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries if (!r.ok) return out; // truncated inside the gain double — out already carries
// mode/marker/velocity/voice fields from above; unity holds // 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 // 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 // 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. // 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(); const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1); 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 // 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 // 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 // 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). // drops the rest (the selection/params behind it are unreadable anyway).
if (version >= kSelectionZonesRefsV10Version) { if (version >= kSelectionRefsV10Version) {
const std::uint32_t refCount = r.u32(); const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
SampleRefEntry e; 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 // 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. // holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) { if (version >= kSelectionRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32(); const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen); out.instanceGuid = r.str(guidLen);
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty 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(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); applyPayload(out, readParamsPayload(r, projectRate));
return out; return out;
} }
+127 -164
View File
@@ -1,125 +1,91 @@
#pragma once #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 // 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'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 // 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 // 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 // link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the // own links are velocity_curve + master_gain (wire value validation), never the engine.
// engine.
// //
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v7) must be preserved exactly. // payload v1..v8) must be preserved exactly.
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <vector> #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 { 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 // The one parameter set is the instrument's OWN state, serialized to the VST3
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant // component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
// of truncation/wrong-version (bounded reads, never throws across the host). // binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
// //
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the // PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
// ZONES PAYLOAD. // 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 // v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
// carries its OWN version, so the per-zone record can grow without bumping the envelope // by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
// version. Zone-record extensions and envelope-field additions stay on independent axes // (dropping a zone touches no file and no bank entry). A single-zone instance therefore
// that can never collide on one version number. // 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 + // * 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 // 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) // rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
// is v1. // * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can // 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record // (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
// 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.
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS // * 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); // a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE // (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames // LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte // Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE // (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product // v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for // threaded into the v3 lift path at read time (a parameter, no baked constant).
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed. // Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were // tier-0 seconds defaults (0.003/0/1.0/0.060).
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by // * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no // AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R // trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060). // unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the // 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte // adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE // frames tail) was never shipped and is intentionally not read.
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1 // * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds + // * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds + // N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is // payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE // (soft hits play louder than under the old linear velocity/127 map).
// 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.
// //
// These two functions serialize the ZONES only; the instrument's full component state is // v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below. // 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; inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker. serializePerformance and // The params-payload format version and its detection marker. The marker is a high sentinel
// serializeComponentState both emit the CURRENT payload version (v7: marker + version + // no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack // a reader detects record shape independent of the envelope version.
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope. inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// 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;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts // (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a // seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives, // (frames / projectRate = seconds) — the same rate the build already receives, so the
// so the seconds domain is consistent across both paths. No constant is baked in. // seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState). // --- Combined component state (VST3 setState/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.
// //
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0 // 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 // 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 // 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned // toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// path + intrinsics + display name per referenced sample; wire shape at // path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — // kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state // 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 // 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 // CURRENT params payload (its own self-describing version). The envelope grows fields on an
// version). The instance guid is the only v11 addition over v10, as the refs table was the // axis INDEPENDENT of the payload version — do NOT bump one for the other.
// 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 // An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
// numbers are independent axes; do NOT bump the zones-payload version for an envelope // blob) falls back to the field's default rather than silencing the instance.
// 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.
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO, // BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice // lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a // 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 // 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 // loaded capture and an EMPTY sample-refs table, which the shell lifts once via the
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell // bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell // publish):
// re-mints on first publish): // * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish). // * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift). // * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode. // * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain. // * v7 blob -> unity master gain.
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults. // * v6 blob -> voice defaults.
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte. // * v5 blob -> no velocity byte.
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker. // * v4 blob -> no marker.
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode. // * v3 blob -> no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection. // * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift. // * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state). // * 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 // ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
// instance re-applying a stale assign_request the user already got and then manually // zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
// changed away from: on re-open the instance re-reads the pending request, and only a // the old first-match resolve actually played, so adopting it is what keeps a single-capture
// generation STRICTLY GREATER than this stored marker re-applies (see // 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 // 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 // assign (generation >= 1) still applies. It is the instrument's own state, never written to
// to the bank — the extension owns the assign_request key; the instrument only tracks what // the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob // no velocity byte lifts to this, audible-but-not-hot.
// with no velocity byte lifts to this, audible-but-not-hot.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64; inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState { struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick std::string selectionId; // the loaded capture; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones InstrumentParams params; // the ONE parameter set governing it
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While // 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 // 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). // choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false; bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of // Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the // the Sample-view preview button retains the user's chosen strike velocity across saves.
// user's chosen strike velocity across saves.
std::uint8_t previewVelocity = kPreviewVelocityDefault; std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT // Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior // reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
// exactly, so an older blob lifting to these plays byte-identically. // byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to // 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 // ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default // voice sum — never per voice. Default unity reproduces pre-master-gain output
// unity reproduces pre-master-gain output byte-identically. // byte-identically.
double masterGainLinear = 1.0; double masterGainLinear = 1.0;
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every // Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
// bank sample this instance plays (see the SampleRefs block above). setState decodes // bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
// straight from these; NO bridge/extension read is required for playback. A pre-v10 // decodes straight from these; NO bridge/extension read is required for playback. A
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path // pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// once (then re-saves self-contained). // path once (then re-saves self-contained).
SampleRefs sampleRefs; SampleRefs sampleRefs;
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>" // 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 // 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 + the minted instance guid, length-prefixed after the refs table. Mirrors the
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing. // 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 // 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: // 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 // (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 // 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). // 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 // Everything through the master gain, no channel-mode explicit flag. Retained so
// deserializeComponentState can lift a v8 blob to implicit mode. // 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 // v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
// in deserializeComponentState is self-describing. // 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. // 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 // fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
// {16, Poly, Retrigger}. // {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. // 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 // Selection + params + channel mode, no consumed marker. Retained so
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. // deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; inline constexpr std::uint32_t kSelectionModeV4Version = 4;
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3 // Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
// blob to {mono, selection, zones}. // blob to {mono, selection, params}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3; inline constexpr std::uint32_t kSelectionV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState). // The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state); 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) -------------------------------- // --- Instance state (VST3 setState/getState) --------------------------------
// //
// The instrument's OWN state is which bank sample it plays (a performance choice, held by // The original v1 instance state was which bank sample it plays — a single string id.
// 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.
// //
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix // 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- // 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 // 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 // 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; 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). // too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes); std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::instrument::map } // 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
+56 -153
View File
@@ -3,7 +3,7 @@
#include "core/instrument/map/sample_map.h" #include "core/instrument/map/sample_map.h"
#include <algorithm> // std::min #include <algorithm> // std::remove_if
#include <cassert> // assert #include <cassert> // assert
#include <utility> // std::move #include <utility> // std::move
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
return out; 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 } // namespace
std::optional<SelectedSample> selectSample(const std::string& banksJson, std::optional<SelectedSample> selectSample(const std::string& banksJson,
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
return nullptr; return nullptr;
} }
std::vector<std::string> referencedSampleIds(const std::string& selectionId, std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
const PerformanceMap& map) {
std::vector<std::string> ids; std::vector<std::string> ids;
const auto addUnique = [&ids](const std::string& id) { if (!selectionId.empty()) ids.push_back(selectionId);
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);
return ids; return ids;
} }
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
return out; return out;
} }
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved, DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) { int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)"); 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 if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate; out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) { if (mode == ChannelMode::Mono) {
@@ -230,7 +202,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
return out; 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 + // seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
// fades) carry through untouched, already frames/fractions. // fades) carry through untouched, already frames/fractions.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); 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; if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5); return static_cast<std::int64_t>(f + 0.5);
}; };
ZonePlayParams out; PlayParams out;
out.playMode = stored.playMode; out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds); out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds); out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
@@ -256,130 +228,61 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
return out; return out;
} }
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate, // --- The one parameter set ----------------------------------------------------
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) { ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)"); 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; SampleData data;
data.frames = std::move(frames); if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
// A second channel only counts when it length-matches channel 0 (else the sample stays assert(decoded.sampleRate > 0 &&
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays). "buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
if (!framesR.empty() && framesR.size() == data.frames.size()) { if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
data.framesR = std::move(framesR); data.frames = std::move(decoded.monoFrames);
}
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));
}
// --- 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() // 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). // enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded[i].framesR.empty() && if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
decoded[i].framesR.size() == data.frames.size()) { data.framesR = std::move(decoded.framesR);
data.framesR = decoded[i].framesR;
} }
assert(decoded[i].sampleRate > 0 && data.sampleRate = decoded.sampleRate;
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)"); data.rootNote = resolved.rootNote;
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first data.loop = resolved.loop;
data.sampleRate = decoded[i].sampleRate; data.startFrame = resolved.startFrame;
data.rootNote = zones[i].rootNote; data.keyTrack = resolved.keyTrack;
data.loop = zones[i].loop; data.velocityCurve = resolved.velocityCurve;
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 // 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. // actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate); data.play = resolvePlay(resolved.play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size(); return data;
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 } // namespace reasampler::instrument::map
+87 -143
View File
@@ -1,10 +1,11 @@
#pragma once #pragma once
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain // 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 // 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 // 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 // bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
// here. Links bank_book, wav_codec, and sampler_core (all pure). // 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 <cstdint>
#include <optional> #include <optional>
@@ -12,7 +13,7 @@
#include <vector> #include <vector>
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse) #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) #include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
@@ -29,7 +30,7 @@ struct SelectedSample {
int rootNote = 60; // defaults to middle C when the bank left it empty int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // hasLoop=false 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) — 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 — // `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 // 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 // its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// the file yields the defined no-play). // the file yields the defined no-play).
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct SampleRefEntry { struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key) 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 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. // 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); const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
// Every bank sample id this instance plays: the selection (when set) + each zone's // Every bank sample id this instance plays. One capture = at most one id; the list form is
// sampleId, de-duplicated, selection first then map order. // kept because the refs-table helpers below are id-set operations.
std::vector<std::string> referencedSampleIds(const std::string& selectionId, std::vector<std::string> referencedSampleIds(const std::string& selectionId);
const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display // 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 // 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); std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract // Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not // channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
// "take L", not summing: a centered mono source stays unity, a hard-panned source is // summing: a centered mono source stays unity, a hard-panned source is attenuated rather
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure. // than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount); int channelCount);
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which); 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 // 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 // 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 // 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 // build. Quantities anchored to the source file's timeline (start point, loop points,
// points, Trigger %-length + fades) stay in source frames/fractions, carried through // Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
// unchanged (TriggerParams reused verbatim). // (TriggerParams reused verbatim).
// //
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. // The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds { struct AdsrSeconds {
@@ -162,10 +160,10 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak double peakSemitones = 0.0; // signed depth at the peak
}; };
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities // The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — // frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
// distinct from sampler_core's engine-facing ZonePlayParams (frames). // from the engine-facing PlayParams (frames).
struct ZonePlaySeconds { struct PlaySeconds {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds) AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames) TriggerParams trigger; // Trigger: %-length + fades (source frames)
@@ -173,46 +171,28 @@ struct ZonePlaySeconds {
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default 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 // sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this). // 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 // --- The instrument's ONE parameter set (its OWN state) -----------------------
// 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 performance map is the keymap the user authors IN the instrument: several bank // One loaded capture, one set of playback parameters governing it across the whole
// samples zoned across the keyboard, each with a key range and a root note. A performance // keyboard. A performance choice, so it lives in the instrument (VST3 component state),
// choice, so it lives in the instrument (VST3 component state), never written back to the // never written back to the bank. Pure value type: names no sample (the ComponentState's
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the // selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames // the pure build stitches the decoded frames + this set into one SampleData.
// + this map into a sampler_core Keymap. //
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range. // the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C // read position are facts about the file, but the instrument may override them without
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial // writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
// read position are facts about the file, but the instrument may override them per zone // read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's // ResolvedCapture.
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into struct InstrumentParams {
// the effective ResolvedZone.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
int highNote = 127; // inclusive
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic 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 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 // 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; double keyTrack = 1.0;
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain, // 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- // replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change — // every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits // predating this field lifts to flat y=1, so an already-saved instance's soft hits play
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd // LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
// in Voice::start. // Voice::start.
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + // Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in // envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults // (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no // tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts // engine, pitch env off. An older blob lacking this tail lifts to exactly these.
// to exactly these defaults on read. PlaySeconds play;
ZonePlaySeconds play;
}; };
// The instrument's performance map: an ordered list of zones. Order is authoritative for // The loaded capture resolved for decode + build: project-relative WAV path (file seam)
// overlap resolution — first zone in order wins (mirrors the core's first-match // plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction. // (which holds optional overrides) — this is the parameter set folded against the capture.
struct PerformanceMap { struct ResolvedCapture {
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 {
std::string relativePath; // project-relative; the shell resolves + decodes it 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 int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET) double keyTrack = 1.0;
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone VelocityCurve velocityCurve = VelocityCurve::flat();
SampleLoop loop; // effective: loopOverride, else bank intrinsic SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 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 // The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped // drift.
// cleanly — not an error, not silence for the whole map — and reported here so the editor ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
// can flag/prune it.
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId // Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note // empty selection, or a stale id -> nullopt.
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// -> empty result.
// //
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the // NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against // refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
// (both share foldZone, so the drift test keeps the shared fold honest). // resolveCapture, so the drift test keeps the shared fold honest).
ResolvedPerformance resolvePerformance(const std::string& banksJson, std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
const PerformanceMap& map); const std::string& selectionId,
const InstrumentParams& params);
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table — // The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone // two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path). std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, const std::string& selectionId,
const PerformanceMap& map); const InstrumentParams& params);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches // Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is struct DecodedPcm {
// 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 {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode) std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid int sampleRate = 0; // 0 is explicitly invalid
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode 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 // 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 // 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode. // `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (average all source channels). // * 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, 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). // * 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). // Empty/zero-channel input -> empty frames (caller plays silence).
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved, DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate); 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, // 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. // 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/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 <algorithm>
#include <cctype> #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 // 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 // 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 // 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 #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 #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" #include "core/ui/rect.h"
@@ -11,174 +12,4 @@ namespace reasampler::instrument::ui {
using Rect = ::reasampler::ui::Rect; using Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains; 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 } // 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 // 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. // without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
@@ -44,31 +44,19 @@ EmbedLayout layoutEmbed(int w, int h) {
return out; 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 Rect& band = layout.keymap;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
int lo = clampNote(lowNote); int lo = clampNote(lowNote);
int hi = clampNote(highNote); 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 leftX = keyEdgeToX(band.x, bandWidth, lo);
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1); const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom()); 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) { Rect levelFillRect(const EmbedLayout& layout, double level) {
const Rect& band = layout.levelBand; const Rect& band = layout.levelBand;
if (band.width <= 0 || band.height <= 0) return Rect{}; if (band.width <= 0 || band.height <= 0) return Rect{};
+9 -22
View File
@@ -3,9 +3,9 @@
// bitmap + mouse coords) into these functions. // bitmap + mouse coords) into these functions.
// //
// A single compact band REAPER draws inline in the track/mixer control panel via the // 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 // Cockos embedded-UI surface: the loaded capture across the keyboard span (MIDI 0..127
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band // mapped to the strip width) with its root marked, plus a thin activity level band at the
// at the bottom. Interaction is zone selection only — no editing. // bottom. Read-only — the strip displays, it never edits.
#pragma once #pragma once
@@ -19,18 +19,10 @@ inline constexpr int kEmbedKeyCount = 128;
inline constexpr int kEmbedLevelBandHeight = 4; inline constexpr int kEmbedLevelBandHeight = 4;
inline constexpr int kEmbedKeymapMinHeight = 6; 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 // Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
// the surface. // the surface.
struct EmbedLayout { struct EmbedLayout {
Rect keymap; // top: zone-segment band Rect keymap; // top: keyboard-span band
Rect levelBand; // bottom: level/activity indicator Rect levelBand; // bottom: level/activity indicator
}; };
@@ -39,16 +31,11 @@ struct EmbedLayout {
// kEmbedKeymapMinHeight); the keymap takes the rest. // kEmbedKeymapMinHeight); the keymap takes the rest.
EmbedLayout layoutEmbed(int w, int h); EmbedLayout layoutEmbed(int w, int h);
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote] // Horizontal sub-rect of the keymap band for the inclusive key span [lowNote, highNote].
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or // Spans the half-open pixel range so adjacent spans tile without a gap or overlap. Notes
// overlap. Notes clamp to [0,127] and low clamps to <= high. // clamp to [0,127] and low clamps to <= high. The loaded capture uses the full span; a
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); // single-key span (low == high) is the root marker.
Rect keySpanRect(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);
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand // Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
// whose width is level * band width. // 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. // 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 // 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 // (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
// every paint), so a node drag and a slider edit are two views on one source of truth. // 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 // 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 // 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. // 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 / // 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 // param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
// the polyline plus a handle at each node (envelope_edit does the hit-test). // AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
// hit-test).
#pragma once #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 // 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) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
@@ -62,41 +62,6 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
return clampNote(note); 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) { bool isNaturalKey(int note) {
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
static constexpr bool kNatural[12] = { 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.h — layout + hit-test + drag math for the editor's keyboard strip.
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws // Mirror of embed_strip/mode_switch; the shell draws and marshals mouse events into these
// and marshals mouse events into these functions. // functions.
// //
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom // 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, // embed_strip uses). The loaded capture responds across that whole span, so the strip's
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a // job is the root marker: click a key, or drag the marker, to set the root.
// bar with edge-grab resize handles + a body move-handle).
#pragma once #pragma once
@@ -17,10 +16,6 @@ namespace reasampler::instrument::ui {
// stay independent. // stay independent.
inline constexpr int kStripKeyCount = 128; 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 // The keys band takes the whole strip area today; clamped so a degenerate size never
// yields an inverted rect. // yields an inverted rect.
struct StripLayout { 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]. // Half-open rect of a single key `note`, clamped to [0,127].
Rect keyRect(const StripLayout& layout, int note); Rect keyRect(const StripLayout& layout, int note);
// Root-marker rect for the single-capture fast path; equivalent to // Root-marker rect; equivalent to keyRect(layout, rootNote) but named so the intent reads
// keyRect(layout, rootNote) but named so the intent reads at the call site. // at the call site.
Rect rootMarkerRect(const StripLayout& layout, int rootNote); Rect rootMarkerRect(const StripLayout& layout, int rootNote);
// MIDI note a point (x, y) lands on, or -1 outside the keys band. // MIDI note a point (x, y) lands on, or -1 outside the keys band.
int keyAtPoint(const StripLayout& layout, int x, int y); 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), // 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 // clamped to [0,127]. The one arithmetic behind the root-marker drag.
// edges with the same delta to preserve span), and root-marker drag.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); 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 // 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`). (`reasampler_vst.h`, `editor_internal.h`).
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`, The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `zone_params.h`, `editor_geometry`, `sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `note_entry`, `sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`, `param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and `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. `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 - 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. 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 / - 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. 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 - Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
headers before use. headers before use.
@@ -86,17 +86,17 @@ scattered `#ifdef`s in the VST shell, except the one described below).
## Modules ## 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. - `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_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` (`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_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/hit-test to `embed_strip`. - `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. - `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.)* - `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 ## Gotchas
- `editor_internal.h` is include-only — it has no TU of its own and must never become - `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 - The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
`reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID. `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 - 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 // editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the // resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack // applyControl — seconds/fraction/frames <-> normalized 0..1), the knob-deck group
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl. // descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam
// Value logic only — no painting, no window plumbing. // converter). Value logic only — no painting, no window plumbing.
#include "shell/instrument/reasampler_editor.h" #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/engine/master_gain.h" // master-gain dB<->linear<->knob taper
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters #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 "core/util/clamp01.h"
#include "shell/instrument/editor_internal.h" // DeckGroup ids #include "shell/instrument/editor_internal.h" // DeckGroup ids
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst { 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::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::formatMasterGainLabel;
using instrument::engine::masterGainLinearFromNorm; using instrument::engine::masterGainLinearFromNorm;
using instrument::engine::masterGainNormFromLinear; using instrument::engine::masterGainNormFromLinear;
@@ -30,7 +36,7 @@ using util::clamp01;
namespace { namespace {
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps // 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] // 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 // 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 — // (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 // 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 } // 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 // 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 // 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 // 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 { int segment) const {
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; 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; return static_cast<double>(processor_->previewVelocity()) / 127.0;
} }
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders // The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group // reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves // flip repopulates in place and never reflows the neighbouring groups.
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
// neighbouring groups.
std::vector<DeckGroupDesc> out; std::vector<DeckGroupDesc> out;
{ {
DeckGroupDesc amp; DeckGroupDesc amp;
@@ -179,14 +194,6 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
static_cast<int>(ParamControl::kPitchEnvDepth)}; static_cast<int>(ParamControl::kPitchEnvDepth)};
out.push_back(std::move(penv)); 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; DeckGroupDesc voice;
voice.id = kGroupVoice; voice.id = kGroupVoice;
@@ -206,22 +213,22 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySecond
return out; return out;
} }
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { double ReaSamplerEditor::deckControlNorm(int id) const {
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
switch (static_cast<ParamControl>(id)) { switch (static_cast<ParamControl>(id)) {
case ParamControl::kKeyTrack: case ParamControl::kKeyTrack:
return clamp01(zone.keyTrack / kKeyTrackMax); return clamp01(params_.keyTrack / kKeyTrackMax);
case ParamControl::kVoiceCount: case ParamControl::kVoiceCount:
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) / return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
static_cast<double>(kMaxVoiceCount - kMinVoiceCount)); static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
case ParamControl::kMasterGain: case ParamControl::kMasterGain:
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0); return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
default: 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; if (!processor_) return;
norm = clamp01(norm); norm = clamp01(norm);
if (id == -2) { if (id == -2) {
@@ -247,15 +254,15 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
return; return;
default: default:
applyZoneControl(zoneIndex, id, norm, 0); applyParamControl(id, norm, 0);
return; return;
} }
} }
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { std::string ReaSamplerEditor::deckValueLabel(int id) const {
char buf[24]; char buf[24];
buf[0] = '\0'; buf[0] = '\0';
const ZonePlaySeconds& play = zone.play; const PlaySeconds& play = params_.play;
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) { switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
case ParamControl::kAttack: case ParamControl::kAttack:
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; 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: case ParamControl::kPitchEnvDepth:
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break; snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
case ParamControl::kKeyTrack: 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: case ParamControl::kVoiceCount:
snprintf(buf, sizeof(buf), "%d", voiceCount_); break; snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
case ParamControl::kMasterGain: case ParamControl::kMasterGain:
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
default: 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; break;
} }
return std::string(buf); return std::string(buf);
@@ -309,7 +316,7 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
return b; 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 { std::int64_t startFrame) const {
AmpEnvelope env; AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; 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.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds; env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played // 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 // point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions. // length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction; 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, 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) { if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds; play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds; play.adsr.holdSeconds = env.holdSeconds;
@@ -342,7 +349,7 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
} else { } else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the trigger-seam converter, unpack direction). startFrame is the // 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 // post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
// so a zero-length trigger never plays nothing. // so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); 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 { void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
// 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)];
if (id == static_cast<int>(ParamControl::kKeyTrack)) { if (id == static_cast<int>(ParamControl::kKeyTrack)) {
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. // keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
z.keyTrack = clamp01(value) * kKeyTrackMax; params_.keyTrack = clamp01(value) * kKeyTrackMax;
} else { } else {
applyControl(id, z.play, value, segment); applyControl(id, params_.play, value, segment);
} }
} }
#endif // _WIN32
} // namespace reasampler::vst } // 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)))); 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. // place of the nav.
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
const std::string& readout) { 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 // 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 // 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) { inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
using instrument::ui::StripLayout; using instrument::ui::StripLayout;
if (stripArea.width <= 0 || stripArea.height <= 0) return; 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 // editor_paint_browse.cpp — the Browse modal's painter: the full-window
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card // select-then-confirm picker (wash, search box, filter tabs, card grid, scrollbar, footer).
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones // Windows-only. Shares the Sample face's title-band + empty-state painters via the class +
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only. // editor_internal.h.
// Shares the Sample face's painters (title band / empty state / deck / curve button /
// popup) via the class + editor_internal.h.
#include "shell/instrument/reasampler_editor.h" #include "shell/instrument/reasampler_editor.h"
@@ -15,15 +13,14 @@
#include <vector> #include <vector>
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry #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 + labels
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_processor.h"
namespace reasampler::vst { namespace reasampler::vst {
using namespace reasampler::ui; // kit vocabulary using namespace reasampler::ui; // kit vocabulary
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry using namespace reasampler::instrument::ui; // browser geometry
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs using namespace reasampler::instrument::map; // SampleChoice / BankChoice
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { 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 // 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 } // namespace reasampler::vst
#endif // _WIN32 #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) { if (self->drag_ != DragKind::kNone) {
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 / // A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
// voice count / master gain) are transient (no map mutation; dragStartMap_ // voice count / master gain) are transient (they mutate no parameter, so
// not snapshotted) — reset drag state only, never touch map_. Every // dragStartParams_ is not a rollback target) — reset drag state only.
// map-editing drag rolls its live mutation back to the snapshot. // Every parameter-editing drag rolls its live mutation back to the snapshot.
const bool transient = self->drag_ == DragKind::kScrollThumb || const bool transient = self->drag_ == DragKind::kScrollThumb ||
(self->drag_ == DragKind::kDeckKnob && (self->drag_ == DragKind::kDeckKnob &&
(self->dragParamId_ == -2 || (self->dragParamId_ == -2 ||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) || self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain))); self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
if (!transient) self->map_ = self->dragStartMap_; if (!transient) self->params_ = self->dragStartParams_;
self->drag_ = DragKind::kNone; self->drag_ = DragKind::kNone;
self->dragParamId_ = -1; self->dragParamId_ = -1;
self->dragParamZone_ = -1;
self->curvePointIndex_ = -1; // curve-node drag state (peer reset) self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
self->dragCurveZone_ = -1;
self->invalidate(); self->invalidate();
} }
} }
+39 -106
View File
@@ -1,8 +1,8 @@
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the // editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the // live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert // commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits // the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
// off the audio thread via the processor's reloadInstrument. // thread via the processor's reloadInstrument.
#include "shell/instrument/reasampler_editor.h" #include "shell/instrument/reasampler_editor.h"
@@ -38,8 +38,8 @@ using util::readFileBytes;
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) { : CPluginView(nullptr), processor_(processor) {
// Default view size, tuned to the Sample-face band heights: title + hero waveform + // Default view size, tuned to the three band heights: chrome + two-lane waveform +
// cluster + control strip. 840x620 clears the full face without scroll on 1080p. // deck row. 840x620 clears the full face without scroll on 1080p.
ViewRect r(0, 0, 840, 620); ViewRect r(0, 0, 840, 620);
setRect(r); setRect(r);
} }
@@ -53,30 +53,21 @@ void ReaSamplerEditor::refreshFromBank() {
banks_.clear(); banks_.clear();
visible_.clear(); visible_.clear();
selectedId_.clear(); selectedId_.clear();
map_.zones.clear(); params_ = InstrumentParams{};
selectedZone_ = -1;
return; return;
} }
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{}; samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId(); selectedId_ = processor_->selectedSampleId();
const auto prevZoneCount = static_cast<int>(map_.zones.size()); params_ = processor_->instrumentParams();
map_ = processor_->performanceMap();
channelMode_ = processor_->channelMode(); channelMode_ = processor_->channelMode();
voiceCount_ = processor_->voiceCount(); voiceCount_ = processor_->voiceCount();
voiceMode_ = processor_->voiceMode(); voiceMode_ = processor_->voiceMode();
monoTrigger_ = processor_->monoTrigger(); 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 // A refresh that emptied the selection closes the curve popup — an open-but-invisible
// modal would otherwise swallow clicks on the empty state. // modal would otherwise swallow clicks on the empty state.
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; if (selectedId_.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;
}
// Drop a filter that names a bank no longer present. // Drop a filter that names a bank no longer present.
if (!activeFilterBankId_.empty()) { if (!activeFilterBankId_.empty()) {
bool found = false; bool found = false;
@@ -127,13 +118,13 @@ void ReaSamplerEditor::onSyncTimer() {
#endif // _WIN32 #endif // _WIN32
void ReaSamplerEditor::commitAndReload() { void ReaSamplerEditor::commitAndReload() {
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio // UI thread only. Publishes the edited selection + parameter set, then rebuilds off the
// thread. The reload also copies the picked capture's file ref + intrinsics into the // audio thread. The reload also copies the loaded capture's file ref + intrinsics into
// instance-owned refs table — a browser load is the moment the instance becomes // the instance-owned refs table — a browser load is the moment the instance becomes
// self-contained for that sample. // self-contained for that sample.
if (!processor_) return; if (!processor_) return;
processor_->setSelectedSampleId(selectedId_); processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_); processor_->setInstrumentParams(params_);
processor_->reloadInstrument(); processor_->reloadInstrument();
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the // The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
// toggle draws what the engine actually decoded with. // toggle draws what the engine actually decoded with.
@@ -144,23 +135,25 @@ void ReaSamplerEditor::commitAndReload() {
} }
void ReaSamplerEditor::loadSelection(const std::string& id) { void ReaSamplerEditor::loadSelection(const std::string& id) {
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized // A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
// full-range zone must not linger, or first-match resolve would keep playing it. // engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
// Authored Zone-view maps (narrow key ranges) are left untouched. // 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; selectedId_ = id;
if (reconcileSingleCaptureZones(map_, selectedId_)) { params_.rootOverride.reset();
selectedZone_ = map_.zones.empty() ? -1 : 0; params_.loopOverride.reset();
} params_.startPoint.reset();
commitAndReload(); commitAndReload();
} }
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m; SetupMarkers m;
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override // Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from // override win (the instrument's performance choice). Read the loop intrinsic from the
// the live bank blob (the same path selectSample uses); when that is not readable (extension // 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 // absent / not yet parsed) the instance-owned ref carries the same intrinsics.
// lives in map_.
if (processor_) { if (processor_) {
std::optional<SelectedSample> sel; std::optional<SelectedSample> sel;
auto banksJson = auto banksJson =
@@ -176,17 +169,13 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
m.loopEnd = sel->loop.end; m.loopEnd = sel->loop.end;
} }
} }
// The override (loop + start) on a zone for the picked id supersedes the intrinsic. // The parameter set's override (loop + start) supersedes the intrinsic.
for (const PerformanceZone& z : map_.zones) { if (params_.loopOverride) {
if (z.sampleId != selectedId_) continue; m.hasLoop = params_.loopOverride->hasLoop;
if (z.loopOverride) { m.loopStart = params_.loopOverride->start;
m.hasLoop = z.loopOverride->hasLoop; m.loopEnd = params_.loopOverride->end;
m.loopStart = z.loopOverride->start;
m.loopEnd = z.loopOverride->end;
}
if (z.startPoint) m.start = *z.startPoint;
break;
} }
if (params_.startPoint) m.start = *params_.startPoint;
// Default an unset loop's end to the sample length so the loop markers have somewhere sane // 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; // 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). // 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; return m;
} }
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The // Write the edited markers into the parameter set as the loop/start override. The bank
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be // intrinsic is never written (read-only bank consumer).
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
// callers can update selectedZone_.
SampleLoop loop; SampleLoop loop;
loop.hasLoop = m.hasLoop; loop.hasLoop = m.hasLoop;
loop.start = m.loopStart; loop.start = m.loopStart;
loop.end = m.loopEnd; loop.end = m.loopEnd;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) { params_.loopOverride = loop;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)]; params_.startPoint = m.start;
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;
} }
int ReaSamplerEditor::effectiveRoot() const { int ReaSamplerEditor::effectiveRoot() const {
int root = 60; if (params_.rootOverride) return *params_.rootOverride;
for (const SampleChoice& s : samples_) { 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) { return 60;
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();
} }
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
+38 -72
View File
@@ -1,5 +1,5 @@
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle: // 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, // 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 // 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 // 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/capture_paths.h" // resolveBankFile (shared path resolution)
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse) #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/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/util/file_bytes.h" // shared whole-file loader
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse) #include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam) #include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
@@ -60,9 +60,8 @@ std::string mintUsageInstanceGuid() {
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only), // 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 // 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 // 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 // resolve/read/decode failure — the caller plays silence.
// zoned build and the single-capture path. std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
const std::string& relativePath, const std::string& relativePath,
ChannelMode mode) { ChannelMode mode) {
const std::string abs = resolveBankFile(projectDir, relativePath); const std::string abs = resolveBankFile(projectDir, relativePath);
@@ -72,7 +71,7 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
if (!layout.valid) return std::nullopt; if (!layout.valid) return std::nullopt;
std::vector<AudioSample> interleaved = std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount()); extractFloatFrames(bytes, layout, 0, layout.frameCount());
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
static_cast<int>(layout.sampleRate)); static_cast<int>(layout.sampleRate));
if (out.monoFrames.empty()) return std::nullopt; if (out.monoFrames.empty()) return std::nullopt;
return out; return out;
@@ -95,8 +94,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
// nothing below — a project restored before PROJEXTSTATE parses (or with the // nothing below — a project restored before PROJEXTSTATE parses (or with the
// extension absent) resolves + plays from the persisted refs. // extension absent) resolves + plays from the persisted refs.
const std::string selId = selectedSampleId(); const std::string selId = selectedSampleId();
const PerformanceMap map = performanceMap(); const InstrumentParams params = instrumentParams();
const std::vector<std::string> ids = referencedSampleIds(selId, map); const std::vector<std::string> ids = referencedSampleIds(selId);
SampleRefs refs; SampleRefs refs;
{ {
std::optional<std::string> banksJson = std::optional<std::string> banksJson =
@@ -110,8 +109,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
} }
const std::string projectDir = bridge_.activeProjectDir(); const std::string projectDir = bridge_.activeProjectDir();
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch // Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
// below may auto-default it before its decode. // capture's own channel count below, before the decode.
ChannelMode mode = channelMode(); ChannelMode mode = channelMode();
// Snapshot the voice-system parameters once — baked into the built engine's // Snapshot the voice-system parameters once — baked into the built engine's
// construction (immutable config; a later change rebuilds). // construction (immutable config; a later change rebuilds).
@@ -127,60 +126,33 @@ std::string ReaSamplerProcessor::reloadInstrument() {
std::string resolvedId; std::string resolvedId;
std::unique_ptr<LoadedInstrument> built; std::unique_ptr<LoadedInstrument> built;
Keymap km; SampleData sample;
bool haveKeymap = false; bool havePlayable = false;
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the // 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread, // repitched from its effective root. No first-sample fallback: an empty selection
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole // (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
// map — the defined no-play, no crash, no retry loop. // rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
if (!map.empty()) { // no-play — no crash, no retry loop.
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();
}
}
// 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)) { if (const SelectedSample* sel = findRef(refs, selId)) {
// Auto-default: channelModeFor computes the mode from the loaded capture's // Auto-default: channelModeFor computes the mode from the loaded capture's channel
// channel count (always 2 for extension captures; mono only for ingest-imported // count (always 2 for extension captures; mono only for ingest-imported mono files).
// mono files). An unknown count (0) or explicit user choice keeps the mode. // An unknown count (0) or explicit user choice keeps the mode.
{ {
std::lock_guard<std::mutex> cm(channelModeMutex_); std::lock_guard<std::mutex> cm(channelModeMutex_);
channelMode_ = channelModeFor(sel->channelCount, channelMode_, channelMode_ = channelModeFor(sel->channelCount, channelMode_,
channelModeExplicit_); channelModeExplicit_);
mode = channelMode_; mode = channelMode_;
} }
std::optional<DecodedZonePcm> pcm = std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
decodeRelative(projectDir, sel->relativePath, mode);
if (pcm) { if (pcm) {
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate, sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
sel->rootNote, sel->loop, havePlayable = sample.playable();
std::move(pcm->framesR)); if (havePlayable) resolvedId = selId; // the concrete pick that resolved
haveKeymap = true;
resolvedId = selId; // the concrete pick that resolved
}
} }
} }
if (haveKeymap) { if (havePlayable) {
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs), // 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 // 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. // 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); kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2; if (preserveWindow < 2) preserveWindow = 2;
built = std::make_unique<LoadedInstrument>( 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); 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 // header). A null `built` (no ref / unreadable WAV) installs silence while any
// displaced tails still ring out via the drain. // displaced tails still ring out via the drain.
publishBuiltLocked(std::move(built)); 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 // 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. // (its play-set), not what decoded — a transiently unreadable WAV stays protected.
publishUsage(refs, ids); publishUsage(refs, ids);
@@ -260,7 +232,7 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
void ReaSamplerProcessor::rebuildVoiceEngine() { void ReaSamplerProcessor::rebuildVoiceEngine() {
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds // 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. // bridge, no disk — and publishes through the same drain-slot swap.
std::lock_guard<std::mutex> lock(reloadMutex_); std::lock_guard<std::mutex> lock(reloadMutex_);
LoadedInstrument* cur = live_.load(std::memory_order_acquire); LoadedInstrument* cur = live_.load(std::memory_order_acquire);
@@ -282,12 +254,12 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2; if (preserveWindow < 2) preserveWindow = 2;
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because // Deep-copy the decoded sample: safe to read concurrently with process() because the
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from // SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
// being freed. // freed.
Keymap km = cur->keymap; SampleData sample = cur->sample;
auto built = std::make_unique<LoadedInstrument>( 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); kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
publishBuiltLocked(std::move(built)); publishBuiltLocked(std::move(built));
} }
@@ -322,7 +294,7 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
const LegacyLiftDecision decision = legacyLiftDecision( const LegacyLiftDecision decision = legacyLiftDecision(
bridge_.readReasamplerExtState(kProjExtBanksKey), bridge_.readReasamplerExtState(kProjExtBanksKey),
referencedSampleIds(selectedSampleId(), performanceMap())); referencedSampleIds(selectedSampleId()));
if (decision == LegacyLiftDecision::Stale) { if (decision == LegacyLiftDecision::Stale) {
// Provably stale: give up permanently. A later bank change that re-introduces an // 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. // id bumps the generation, and genChanged refreshes the refs without this latch.
@@ -379,15 +351,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
if (decision.apply) { if (decision.apply) {
// Apply as this instance's own selection (the instrument updates its own state, // 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); 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; result.applied = true;
} }
@@ -412,8 +379,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
// dependency (a v10 blob plays from its refs with no poll at all). // dependency (a v10 blob plays from its refs with no poll at all).
bool legacyLift = false; bool legacyLift = false;
if (!genChanged && !result.applied && sampleRefs().empty()) { if (!genChanged && !result.applied && sampleRefs().empty()) {
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
legacyLift = hasIntent && legacyLiftShouldRun();
} }
if (genChanged || result.applied || legacyLift) { if (genChanged || result.applied || legacyLift) {
+18 -23
View File
@@ -1,6 +1,6 @@
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState // processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
// against the component_state_io codec) and its UI-thread parameter accessors/setters // 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; // 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 // setters hand work to the reload family (processor_reload.cpp) or store atomics
// process() picks up at block start. // 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/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
#include "core/instrument/map/component_state_io.h" // the ComponentState codec #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;
using namespace Steinberg::Vst; 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) { while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
bytes.insert(bytes.end(), chunk, chunk + got); bytes.insert(bytes.end(), chunk, chunk + got);
} }
// Component state is {single-capture selection id, opt-in zones}, restored explicitly // Component state is {loaded capture id, one parameter set}. deserializeComponentState
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState // lifts older blobs cleanly — including the retired zone payloads, which adopt zone
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_ // one's capture into cs.selectionId. sampleRate_ is the real host rate here (REAPER
// is the real host rate here — REAPER calls setupProcessing before setState on load. // calls setupProcessing before setState on load), which the legacy v3 payload's
// frames->seconds conversion needs.
const ComponentState cs = deserializeComponentState(bytes, sampleRate_); const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
setSelectedSampleId(cs.selectionId); setSelectedSampleId(cs.selectionId);
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones setInstrumentParams(cs.params);
// (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);
// Restore the last-consumed assignment generation so a re-open does not re-apply a // Restore the last-consumed assignment generation so a re-open does not re-apply a
// stale assign_request. // stale assign_request.
{ {
@@ -95,11 +91,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
if (!state) return kResultFalse; if (!state) return kResultFalse;
// Persists the full instance state — never written to the "reasampler" bank ext-state. // 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 // No pick serializes to {"", default params}, restoring as silence (never auto-playing
// auto-playing sample #1). // sample #1).
ComponentState state_out; ComponentState state_out;
state_out.selectionId = selectedSampleId(); state_out.selectionId = selectedSampleId();
state_out.map = performanceMap(); state_out.params = instrumentParams();
{ {
std::lock_guard<std::mutex> lock(channelModeMutex_); std::lock_guard<std::mutex> lock(channelModeMutex_);
state_out.channelMode = channelMode_; 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 // present. Filtered (snapshot copy only) to what the instance currently plays, so the
// table cannot grow with browsing history. // table cannot grow with browsing history.
state_out.sampleRefs = sampleRefs(); state_out.sampleRefs = sampleRefs();
retainRefs(state_out.sampleRefs, retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId));
referencedSampleIds(state_out.selectionId, state_out.map));
// Persist the publish identity so the usage key is stable across sessions. // Persist the publish identity so the usage key is stable across sessions.
{ {
std::lock_guard<std::mutex> lock(usageMutex_); std::lock_guard<std::mutex> lock(usageMutex_);
@@ -147,14 +142,14 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
selectedSampleId_ = id; selectedSampleId_ = id;
} }
PerformanceMap ReaSamplerProcessor::performanceMap() { InstrumentParams ReaSamplerProcessor::instrumentParams() {
std::lock_guard<std::mutex> lock(performanceMutex_); std::lock_guard<std::mutex> lock(paramsMutex_);
return performanceMap_; return params_;
} }
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
std::lock_guard<std::mutex> lock(performanceMutex_); std::lock_guard<std::mutex> lock(paramsMutex_);
performanceMap_ = map; params_ = params;
} }
SampleRefs ReaSamplerProcessor::sampleRefs() { 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: // 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 // 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 // (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in // face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
// the pure modules; every edit commits off the audio thread via reloadInstrument. // 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 #pragma once
@@ -16,9 +17,11 @@
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type) #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_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/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/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) #include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
#ifdef _WIN32 #ifdef _WIN32
@@ -32,25 +35,26 @@ namespace reasampler::vst {
using audio::AudioSample; using audio::AudioSample;
using audio::Envelope; using audio::Envelope;
using instrument::map::BankChoice; using instrument::map::BankChoice;
using instrument::map::PerformanceMap; using instrument::map::InstrumentParams;
using instrument::map::PerformanceZone; using instrument::map::PlaySeconds;
using instrument::map::SampleChoice; using instrument::map::SampleChoice;
using instrument::map::SampleRefEntry; using instrument::map::SampleRefEntry;
using instrument::map::SampleRefs; using instrument::map::SampleRefs;
using instrument::map::ZonePlaySeconds;
using instrument::ui::AmpEnvelope; using instrument::ui::AmpEnvelope;
using instrument::ui::ChromeRects;
using instrument::ui::DeckGroupDesc; using instrument::ui::DeckGroupDesc;
using instrument::ui::EnvClampBounds; using instrument::ui::EnvClampBounds;
using instrument::ui::EnvNode; using instrument::ui::EnvNode;
using instrument::ui::Rect; using instrument::ui::Rect;
using instrument::ui::SampleBands;
class ReaSamplerProcessor; class ReaSamplerProcessor;
class ReaSamplerEditor : public Steinberg::CPluginView { class ReaSamplerEditor : public Steinberg::CPluginView {
public: public:
// `processor` outlives this editor; the editor reads the live bank through it and drives // `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 // selection/parameter edits + reload on user input. May be null (defensive; a real host
// supplies one). // always supplies one).
explicit ReaSamplerEditor(ReaSamplerProcessor* processor); explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
~ReaSamplerEditor() override; ~ReaSamplerEditor() override;
@@ -65,18 +69,18 @@ protected:
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
private: private:
// Sample is the home/default face. Browse is a full-window modal picker overlaid on // Sample is the home/default face (the three-band stack). Browse is a full-window modal
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned. // picker overlaid on it.
enum class View { kSample, kBrowse, kZone }; enum class View { kSample, kBrowse };
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their // What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored // grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_). // knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob }; kCurveNode, kDeckKnob };
// Controls on the setup surface. The int value is the opaque control id the pure // 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. // processor-side per-instance setter.
enum class ParamControl { enum class ParamControl {
kPlayMode = 0, // Gate | Trigger toggle kPlayMode = 0, // Gate | Trigger toggle
@@ -93,9 +97,9 @@ private:
kPitchEnvAttack, // AD pitch attack kPitchEnvAttack, // AD pitch attack
kPitchEnvDecay, // AD pitch decay kPitchEnvDecay, // AD pitch decay
kPitchEnvDepth, // AD pitch depth in +/- semitones kPitchEnvDepth, // AD pitch depth in +/- semitones
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to // Deck-only controls: processor-side per-instance params — routed to the processor
// the processor setters, never through applyZoneControl / the map. // setters, never through applyParamControl.
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
kVoiceMode, // Poly | Mono caption toggle (VOICE group) kVoiceMode, // Poly | Mono caption toggle (VOICE group)
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono) kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
@@ -103,8 +107,8 @@ private:
kCount kCount
}; };
// The waveform markers on the single-capture setup surface: start-point + the sustain // The waveform markers on the waveform band: start-point + the sustain loop's two ends,
// loop's two ends, in draw + hit order. // in draw + hit order.
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 }; enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index` // The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
@@ -112,9 +116,8 @@ private:
// not applicable. // not applicable.
enum class HoverKind { enum class HoverKind {
kNone, kNone,
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal) kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
kNavZone, // the Sample-view "Zone" title-band button (opens the Zone surface) kBack, // the Browse "back" affordance (returns to Sample)
kBack, // the Browse/Zone "back" affordance (returns to Sample)
kSearchBox, // the browser search box kSearchBox, // the browser search box
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All) kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
kCard, // a capture card (index = visible_ index) kCard, // a capture card (index = visible_ index)
@@ -122,13 +125,11 @@ private:
kBrowseCancel, // the Browse modal "Cancel" button kBrowseCancel, // the Browse modal "Cancel" button
kChanMono, // the mono channel-mode segment kChanMono, // the mono channel-mode segment
kChanStereo, // the stereo channel-mode segment kChanStereo, // the stereo channel-mode segment
kPreview, // the Sample-view preview-trigger button kPreview, // the preview-trigger button
kAddZone, // the "+ Add Zone" button
kDeleteZone, // the "Delete" zone button
kControl, // a knob-deck element (index = control id) kControl, // a knob-deck element (index = control id)
kCurveNode, // a velocity-curve control point (index = point index) kCurveNode, // a velocity-curve control point (index = point index)
kVelKnob, // the cluster preview-velocity radial knob kVelKnob, // the chrome preview-velocity radial knob
kCurveButton, // the cluster mini curve-preview button (opens the popup) kCurveButton, // the chrome mini curve-preview button (opens the popup)
kPopupClose, // the curve popup's Close (x) button kPopupClose, // the curve popup's Close (x) button
}; };
struct HoverTarget { struct HoverTarget {
@@ -138,60 +139,82 @@ private:
bool operator!=(const HoverTarget& o) const { return !(*this == o); } 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 #ifdef _WIN32
void paint(HDC hdc); 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 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); void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
// The knob deck: group fence + caption + compact caption toggles + radial knobs with // --- Band painters (one TU each, mirroring the input side) ---
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs // Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top). // curve button, channel toggle).
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone, void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
const std::vector<DeckGroupDesc>& descs); // Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
// The mini curve-preview button shared by the Sample cluster + the Zone panel. void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone); // Decks: the group fence + caption + compact caption toggles + radial knobs with
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site // label<->value swap on hover/drag.
// or the Zone surface's selected zone. 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); 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 // 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. // host is the popup sheet. `r` empty -> draws nothing.
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone); void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab // full band height (never per lane).
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, std::int64_t frames);
// 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);
// --- Input: the mouse-down dispatch and its per-band branches ---
void onMouseDown(int x, int y); void onMouseDown(int x, int y);
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in // Each returns true when it consumed the click. Called in band order by onMouseDown.
// editor_input_browse_zone.cpp). 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 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 onMouseMove(int x, int y);
void onMouseUp(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 // 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). // popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
void onMouseRDown(int x, int y); void onMouseRDown(int x, int y);
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary // Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar // Alt-click on an interior node deletes it at once; an empty-space click adds a point
// (0..200% over the knob's 0..1). // and grabs it.
void applyZoneControl(int zoneIndex, int id, double value, int segment); 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. // 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); 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 { bool isHovered(HoverKind kind, int index) const {
return hover_.kind == kind && hover_.index == index; return hover_.kind == kind && hover_.index == index;
} }
@@ -215,16 +238,15 @@ private:
#endif #endif
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's // 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(); void refreshFromBank();
// Publishes the edited zones/selection to the processor, then rebuilds the instrument // Publishes the edited selection + parameters to the processor, then rebuilds the
// off the audio thread. UI thread only. // instrument off the audio thread. UI thread only.
void commitAndReload(); void commitAndReload();
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones // Commits `id` as the loaded capture. The one parameter set carries over — it governs
// first so the previous sample's materialized full-range zone cannot linger and shadow // whatever is loaded, so a load swaps the sound, not the settings.
// the new pick under first-match resolve, then publishes + reloads.
void loadSelection(const std::string& id); void loadSelection(const std::string& id);
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then // 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. // thread only (file I/O); cleared with the thumbnail cache on refresh.
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId); const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
// The effective loop + start markers for the picked capture: the per-zone override when // The effective loop + start markers for the loaded capture: the parameter set's
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop -> // 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. // loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
struct SetupMarkers { struct SetupMarkers {
std::int64_t start = 0; std::int64_t start = 0;
@@ -251,94 +273,74 @@ private:
}; };
SetupMarkers pickedMarkers(std::int64_t frames) const; SetupMarkers pickedMarkers(std::int64_t frames) const;
// Commits an edited marker set for the picked capture as a per-zone loop/start override // Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
// (upsert on the picked id), then reloads off-thread. // callers decide live-drag vs final commit.
void commitPickedMarkers(const SetupMarkers& m); void applyMarkers(const SetupMarkers& m);
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append). // Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_ // pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
// 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.
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over // 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 // a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
// centered at 0.5). // 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 // Applies a committed control interaction to `play`: a knob's normalized `value` or a
// toggle's `segment` (0/1). Mutates `play` in place. // 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 // 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; double fadeMaxFrames() const;
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span, // 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 // envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
// seconds are rate-free and copy 1-to-1. // seconds are rate-free and copy 1-to-1.
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective // PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
// start point (zone.startPoint.value_or(0)). AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const; 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, 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 // Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
// never produce a param a slider couldn't. // never produce a param a slider couldn't.
EnvClampBounds envClampBounds() const; EnvClampBounds envClampBounds() const;
// The Sample face and the Zone surface read/write the same one-zone map site. // The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
// 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.
int effectiveRoot() const; int effectiveRoot() const;
// The live sample rate from the bridge, or 0 when unavailable (caller guards). // The live sample rate from the bridge, or 0 when unavailable (caller guards).
double liveSampleRate() const; double liveSampleRate() const;
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]). // Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
double previewVelocity01() const; double previewVelocity01() const;
// Find-or-materializes the one-zone override for the picked id, appending a // The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two
// product-default zone if none exists. Mirror of upsertPickedOverride for a control // reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH
// edit. Returns -1 if selectedId_ is empty. // ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) /
int ensureSampleZone(); // 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 // The normalized [0,1] value a deck knob shows — parameter-set ids route through
// 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
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity // controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
// via the -2 sentinel) read the processor's live value. // 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 // Applies a deck-knob value: parameter-set ids write params_ (commit on release);
// release); processor params write through the processor setters immediately // processor params write through the processor setters immediately (transient — no
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids. // params edit, no reload).
void applyDeckKnob(int zoneIndex, int id, double norm); void applyDeckKnob(int id, double norm);
// The knob's live value label shown during hover/drag: seconds, percents, source // The knob's live value label shown during hover/drag: seconds, percents, source
// frames, signed semitones, a voice count, or the master-gain dB. // 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; ReaSamplerProcessor* processor_ = nullptr;
@@ -346,8 +348,8 @@ private:
std::vector<SampleChoice> samples_; // every bank sample, bank order std::vector<SampleChoice> samples_; // every bank sample, bank order
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
std::string selectedId_; // the single-capture pick ("" = empty state) std::string selectedId_; // the loaded capture ("" = empty state)
PerformanceMap map_; // the opt-in zones (empty = no zones) InstrumentParams params_; // the ONE parameter set governing it
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the // Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
@@ -357,10 +359,9 @@ private:
VoiceMode voiceMode_ = VoiceMode::Poly; VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; 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 View view_ = View::kSample; // default face is the loaded-sample home
std::string activeFilterBankId_; // "" = All; else a bank id from banks_ 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; // 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. // 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 std::string searchQuery_; // type-to-filter narrow; "" = no search
bool searchFocused_ = false; // whether the search box has keyboard focus 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). // Hover state (transient, never persisted).
HoverTarget hover_; // the interactive element under the pointer HoverTarget hover_; // the interactive element under the pointer
#ifdef _WIN32 #ifdef _WIN32
@@ -398,10 +394,8 @@ private:
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag 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 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 dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove
int dragStartLow_ = 0; // the grabbed field's note at grab time int dragStartRoot_ = 60; // the root note at grab time
int dragStartHigh_ = 0; InstrumentParams dragStartParams_; // params_ snapshotted at grab; restored on capture-loss
int dragStartRoot_ = 60;
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the // 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 // pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
@@ -409,12 +403,11 @@ private:
WaveMarker waveMarker_ = WaveMarker::kStart; WaveMarker waveMarker_ = WaveMarker::kStart;
SetupMarkers dragStartMarkers_; SetupMarkers dragStartMarkers_;
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag 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 dragStartScrollOffset_ = 0;
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob 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 // Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
// contract, per envelope_edit's grabEnv). // contract, per envelope_edit's grabEnv).
@@ -422,19 +415,16 @@ private:
AmpEnvelope dragStartEnv_{}; AmpEnvelope dragStartEnv_{};
// Velocity-curve node drag: which point, the curve snapshotted at grab // Velocity-curve node drag: which point, the curve snapshotted at grab
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone // (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
// place the editor differently), and which zone the edit lands on.
int curvePointIndex_ = -1; int curvePointIndex_ = -1;
VelocityCurve dragStartCurve_ = VelocityCurve::flat(); VelocityCurve dragStartCurve_ = VelocityCurve::flat();
Rect dragCurveRect_{}; Rect dragCurveRect_{};
int dragCurveZone_ = -1;
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel // Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
// delta from this anchor, so a grab never jumps the value. // delta from this anchor, so a grab never jumps the value.
double dragKnobStartValue_ = 0.0; double dragKnobStartValue_ = 0.0;
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a // Curve popup open flag, never persisted.
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
bool curvePopupOpen_ = false; bool curvePopupOpen_ = false;
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize // Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
+44 -64
View File
@@ -4,6 +4,7 @@
#include "shell/instrument/reasampler_embed.h" #include "shell/instrument/reasampler_embed.h"
#include <algorithm>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -62,15 +63,6 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
} }
#endif #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 } // namespace
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) { 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; 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() { void ReaSamplerEmbed::refresh() {
if (!processor_) { if (!processor_) {
samples_.clear(); samples_.clear();
map_.zones.clear(); loadedId_.clear();
selectedZone_ = -1; rootNote_ = 60;
return; return;
} }
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{}; samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
map_ = processor_->performanceMap(); refreshLoaded();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
} }
void ReaSamplerEmbed::maybeRefresh() { void ReaSamplerEmbed::maybeRefresh() {
if (!processor_) { refresh(); return; } // clears state; cheap if (!processor_) { refresh(); return; } // clears state; cheap
// The performance map is a cheap in-process accessor, and the editor may edit zones // The loaded capture + root are cheap in-process accessors, and the editor may change
// with no bank-content change — always re-snapshot it so an edit reflects immediately. // either with no bank-content change — always re-snapshot so an edit reflects at once.
map_ = processor_->performanceMap(); refreshLoaded();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// The expensive part is the bank-blob bridge read: gate it on the bank-generation // 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 // 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 #ifdef _WIN32
case REAPER_FXEMBED_WM_PAINT: case REAPER_FXEMBED_WM_PAINT:
return paint(parm2, parm3) ? 1 : 0; 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 #endif
default: default:
return 0; // unhandled messages (cursor, wheel, hittest) fall through 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); const EmbedLayout layout = layoutEmbed(w, h);
if (map_.zones.empty()) { if (loadedId_.empty()) {
// No opt-in zones authored: a faint band so the strip reads as "present, no zones" // Nothing loaded: a faint band so the strip reads as "present, silent" — the pick
// — the default single-capture face lives in the editor. // affordance lives in the editor.
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width, LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0); layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
const std::string label = version::vstPluginName() + // channel-derived 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(), const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
layout.keymap.bottom()); layout.keymap.bottom());
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left); text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
} else { } else {
// Each zone draws as a segment (first-match order, matching selection/playback), // The loaded capture spans the whole keyboard, drawn in its root's spectral hue so
// colored by its key span's spectral hue so it reads as the same spectrum as the // the strip reads as the same spectrum as the editor's keyboard strip; the root key
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow. // lifts to accent-primary with a static glow ("this is where it plays at unity").
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) { const Rect span = keySpanRect(layout, 0, 127);
const PerformanceZone& z = map_.zones[i]; LICE_FillRect(bmp, span.x, span.y, span.width, span.height,
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); toLice(spectralColor(rootNote_ / 127.0)), 0.65f, 0);
if (r.width <= 0) continue; LICE_DrawRect(bmp, span.x, span.y, span.width - 1, span.height - 1,
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); toLice(roleColor(Role::LineHairline)), 1.0f, 0);
// Label when wide enough to read; the selected (accent-fill) segment labels in const Rect root = keySpanRect(layout, rootNote_, rootNote_);
// bg/base for contrast, the rest in text/primary. const int rw = (std::max)(2, root.width);
if (r.width >= 24) { LICE_FillRect(bmp, root.x - 2, root.y, rw + 4, root.height,
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom()); toLice(roleColor(Role::AccentHot)), 0.30f, 0);
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(), LICE_FillRect(bmp, root.x, root.y, rw, root.height,
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left); 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; 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 #endif // _WIN32
} // namespace reasampler::vst } // namespace reasampler::vst
+14 -14
View File
@@ -2,8 +2,9 @@
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in // 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 // 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. // 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 // The strip's layout is pure (embed_strip.h, unit-tested); this shell marshals REAPER's
// REAPER's messages to/from it. // 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 #pragma once
@@ -13,7 +14,7 @@
#include "pluginterfaces/base/funknown.h" #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 // 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 // pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
@@ -26,7 +27,6 @@ namespace reasampler::vst {
class ReaSamplerProcessor; class ReaSamplerProcessor;
using instrument::map::PerformanceMap;
using instrument::map::SampleChoice; using instrument::map::SampleChoice;
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr, // Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
@@ -59,17 +59,19 @@ private:
#ifdef _WIN32 #ifdef _WIN32
// Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. // Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo); 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 #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(); void refresh();
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation // 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 // stamp changed since the last paint. The loaded capture + root are always refreshed
// in-process accessor) so a zone edit reflects immediately. UI thread only. // (cheap in-process accessors) so an edit reflects immediately. UI thread only.
void maybeRefresh(); void maybeRefresh();
ReaSamplerProcessor* processor_ = nullptr; ReaSamplerProcessor* processor_ = nullptr;
@@ -77,10 +79,8 @@ private:
// from a real generation 0, forcing the first maybeRefresh() to do a full read. // from a real generation 0, forcing the first maybeRefresh() to do a full read.
std::int64_t lastSeenBankGeneration_ = -1; std::int64_t lastSeenBankGeneration_ = -1;
std::vector<SampleChoice> samples_; std::vector<SampleChoice> samples_;
PerformanceMap map_; std::string loadedId_; // the loaded capture's bank id; "" = nothing loaded
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's int rootNote_ = 60; // effective root: override, else the capture's own intrinsic
// highlight.
int selectedZone_ = -1;
}; };
} // namespace reasampler::vst } // namespace reasampler::vst
+27 -27
View File
@@ -17,37 +17,37 @@
#include "public.sdk/source/vst/vstsinglecomponenteffect.h" #include "public.sdk/source/vst/vstsinglecomponenteffect.h"
#include "shell/instrument/reaper_bridge.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/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split) #include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/sampler_core.h" #include "core/instrument/engine/voice_engine.h"
namespace reasampler::vst { namespace reasampler::vst {
using instrument::map::ComponentState; using instrument::map::ComponentState;
using instrument::map::PerformanceMap; using instrument::map::InstrumentParams;
using instrument::map::SampleRefs; using instrument::map::SampleRefs;
using instrument::map::kPreviewVelocityDefault; using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface) class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
// Decoded keymap + the voice engine playing it. The engine holds references into the // The decoded capture + the voice engine playing it. The engine holds a reference to the
// keymap, so both must live/die together at a stable address — heap-allocated, // 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. // non-copyable, non-movable. process() only ever reads this through an atomic pointer.
struct LoadedInstrument { struct LoadedInstrument {
Keymap keymap; SampleData sample;
VoiceEngine engine; VoiceEngine engine;
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_ 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 // 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 // off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
// of clicking. // 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::uint64_t gen, std::size_t preserveVoiceCap = 0,
std::int64_t preserveWindowFrames = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly, VoiceMode voiceMode = VoiceMode::Poly,
MonoTrigger monoTrigger = MonoTrigger::Retrigger) MonoTrigger monoTrigger = MonoTrigger::Retrigger)
: keymap(std::move(km)), : sample(std::move(sd)),
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames, engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
voiceMode, monoTrigger, /*takeoverDeclick=*/true), voiceMode, monoTrigger, /*takeoverDeclick=*/true),
installedAt(gen) {} installedAt(gen) {}
@@ -111,7 +111,7 @@ public:
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed)); 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 // 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 // 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 // (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 // The live host sample rate latched from setupProcessing; the editor's envelope overlay
// shares this time base. 0.0 before setupProcessing runs. // shares this time base. 0.0 before setupProcessing runs.
double sampleRate() const { return sampleRate_; } double sampleRate() const { return sampleRate_; }
// The single-capture selection id (guarded by selectionMutex_, never read on the audio // The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
// thread): the default face's pick when the performance map is empty; a non-empty map // Empty id -> silence, no first-sample fallback.
// supersedes it. Empty id -> silence, no first-sample fallback.
std::string selectedSampleId(); std::string selectedSampleId();
void setSelectedSampleId(const std::string& id); void setSelectedSampleId(const std::string& id);
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never // The one parameter set governing that capture. UI thread, guarded by paramsMutex_;
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread. // never read on the audio thread — reloadInstrument bakes it into the SampleData
PerformanceMap performanceMap(); // off-thread.
void setPerformanceMap(const PerformanceMap& map); InstrumentParams instrumentParams();
void setInstrumentParams(const InstrumentParams& params);
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read // 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 // 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); void setPreviewVelocity(std::uint8_t velocity);
// Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the // Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no // audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded SampleData,
// bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail. // no bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
int voiceCount(); int voiceCount();
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode(); VoiceMode voiceMode();
@@ -203,7 +203,7 @@ private:
void retireIdleDrain(); void retireIdleDrain();
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's // 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. // swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
void rebuildVoiceEngine(); void rebuildVoiceEngine();
@@ -253,15 +253,15 @@ private:
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read // The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
// on the audio thread. // audio thread.
std::mutex selectionMutex_; std::mutex selectionMutex_;
std::string selectedSampleId_; std::string selectedSampleId_;
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into // The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
// the Keymap under the reload lock, never read directly on the audio thread. // under the reload lock, never read directly on the audio thread.
std::mutex performanceMutex_; std::mutex paramsMutex_;
PerformanceMap performanceMap_; InstrumentParams params_;
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed // Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
// opportunistically from the bank blob when readable; never a bank dependency for // 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. // and returning every index for an empty query.
#include "../src/core/instrument/ui/browser_scroll.h" #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 <cstdio>
#include <string> #include <string>
+753 -149
View File
@@ -1,14 +1,16 @@
// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite the full // component_state_io unit tests — the codec's whole suite: the current envelope + params
// envelope/payload version ladder, every legacy lift, the golden byte fixtures — // payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
// lives in test_sample_map.cpp and runs unmodified against the split module; this // version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
// target exists as the module's OWN executable (house rule: every pure module has // -> the one parameter set, adopting zone one). The codec's own executable is also the
// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine // STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
// (T2-07): it links component_state_io + velocity_curve + master_gain only — a // velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
// sampler_core/pitch_shift symbol reaching this link is a regression. // link is a regression.
#include "../src/core/instrument/map/component_state_io.h" #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 <cstdio>
#include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -25,7 +27,190 @@ static int failures = 0;
} \ } \
} while (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() { static void testComponentStateRoundTrip() {
ComponentState in; ComponentState in;
in.selectionId = "smp-1"; in.selectionId = "smp-1";
@@ -48,18 +233,30 @@ static void testComponentStateRoundTrip() {
e.ref.channelCount = 2; e.ref.channelCount = 2;
e.displayName = "My Capture"; e.displayName = "My Capture";
in.sampleRefs.push_back(e); in.sampleRefs.push_back(e);
PerformanceZone z; in.params.rootOverride = 61;
z.sampleId = "smp-1"; SampleLoop lp;
z.lowNote = 30; lp.hasLoop = true;
z.highNote = 90; lp.start = 7;
z.rootOverride = 61; lp.end = 900;
z.startPoint = 5; in.params.loopOverride = lp;
z.keyTrack = 1.5; in.params.startPoint = 5;
z.play.playMode = PlayMode::Trigger; in.params.keyTrack = 1.5;
z.play.trigger.lengthFraction = 0.75; in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
z.play.trigger.fadeInFrames = 441; {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
z.play.trigger.fadeOutFrames = 882; in.params.play.playMode = PlayMode::Trigger;
in.map.zones.push_back(z); 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 std::vector<std::uint8_t> bytes = serializeComponentState(in);
const ComponentState out = deserializeComponentState(bytes, 48000.0); 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].ref.channelCount == 2);
CHECK(out.sampleRefs[0].displayName == "My Capture"); CHECK(out.sampleRefs[0].displayName == "My Capture");
} }
CHECK(out.map.zones.size() == 1); const InstrumentParams& p = out.params;
if (out.map.zones.size() == 1) { CHECK(p.rootOverride && *p.rootOverride == 61);
const PerformanceZone& oz = out.map.zones[0]; CHECK(p.loopOverride && p.loopOverride->hasLoop);
CHECK(oz.sampleId == "smp-1"); CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
CHECK(oz.lowNote == 30); CHECK(p.startPoint && *p.startPoint == 5);
CHECK(oz.highNote == 90); CHECK(p.keyTrack == 1.5);
CHECK(oz.rootOverride && *oz.rootOverride == 61); CHECK(p.velocityCurve.points().size() == 3);
CHECK(oz.startPoint && *oz.startPoint == 5); CHECK(p.play.playMode == PlayMode::Trigger);
CHECK(oz.keyTrack == 1.5); CHECK(p.play.adsr.attackSeconds == 0.01);
CHECK(oz.play.playMode == PlayMode::Trigger); CHECK(p.play.adsr.holdSeconds == 0.05);
CHECK(oz.play.trigger.lengthFraction == 0.75); CHECK(p.play.adsr.decaySeconds == 0.02);
CHECK(oz.play.trigger.fadeInFrames == 441); CHECK(p.play.adsr.sustainLevel == 0.8);
CHECK(oz.play.trigger.fadeOutFrames == 882); 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 // GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the // pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would // byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field // pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
// family at once (two zones — one Trigger with every optional override set, one Gate with all // field family at once (a two-entry sample-refs table — one with a loop, one without — every
// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode // optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected // and asserts the encoded bytes equal an EXACT expected vector, captured from the current
// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the // writer's output and checked field-for-field against the v8/v11 layout documented in
// round-trip test above) captured as the golden — so the byte layout itself becomes // component_state_io.h.
// un-driftable, not just its first 5 bytes.
static void testGoldenFullBlobFixture() { static void testGoldenFullBlobFixture() {
ComponentState in; ComponentState in;
in.selectionId = "kick"; in.selectionId = "kick";
@@ -146,51 +349,30 @@ static void testGoldenFullBlobFixture() {
snareRef.displayName = "Snare"; snareRef.displayName = "Snare";
in.sampleRefs.push_back(snareRef); in.sampleRefs.push_back(snareRef);
// Zone A: every optional field present, Trigger mode, non-flat velocity curve. in.params.rootOverride = 36;
PerformanceZone zoneA;
zoneA.sampleId = "kick";
zoneA.lowNote = 24;
zoneA.highNote = 60;
zoneA.rootOverride = 36;
SampleLoop loopA; SampleLoop loopA;
loopA.hasLoop = true; loopA.hasLoop = true;
loopA.start = 1000; loopA.start = 1000;
loopA.end = 5000; loopA.end = 5000;
zoneA.loopOverride = loopA; in.params.loopOverride = loopA;
zoneA.startPoint = 250; in.params.startPoint = 250;
zoneA.keyTrack = 0.5; in.params.keyTrack = 0.5;
zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints( in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}}); {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
zoneA.play.playMode = PlayMode::Trigger; in.params.play.playMode = PlayMode::Trigger;
zoneA.play.adsr.attackSeconds = 0.01; in.params.play.adsr.attackSeconds = 0.01;
zoneA.play.adsr.holdSeconds = 0.05; in.params.play.adsr.holdSeconds = 0.05;
zoneA.play.adsr.decaySeconds = 0.02; in.params.play.adsr.decaySeconds = 0.02;
zoneA.play.adsr.sustainLevel = 0.8; in.params.play.adsr.sustainLevel = 0.8;
zoneA.play.adsr.releaseSeconds = 0.15; in.params.play.adsr.releaseSeconds = 0.15;
zoneA.play.trigger.lengthFraction = 0.75; in.params.play.trigger.lengthFraction = 0.75;
zoneA.play.trigger.fadeInFrames = 100; in.params.play.trigger.fadeInFrames = 100;
zoneA.play.trigger.fadeOutFrames = 200; in.params.play.trigger.fadeOutFrames = 200;
zoneA.play.pitchEngine = PitchEngine::Preserve; in.params.play.pitchEngine = PitchEngine::Preserve;
zoneA.play.pitchEnv.enabled = true; in.params.play.pitchEnv.enabled = true;
zoneA.play.pitchEnv.attackSeconds = 0.02; in.params.play.pitchEnv.attackSeconds = 0.02;
zoneA.play.pitchEnv.decaySeconds = 0.03; in.params.play.pitchEnv.decaySeconds = 0.03;
zoneA.play.pitchEnv.peakSemitones = 5.0; in.params.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);
const std::vector<std::uint8_t> bytes = serializeComponentState(in); const std::vector<std::uint8_t> bytes = serializeComponentState(in);
// clang-format off // 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,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, 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, 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, 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00, 0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00, 0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, 0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40, 0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f, 0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f, 0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00, 0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
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,
}; };
// clang-format on // clang-format on
CHECK(bytes.size() == sizeof(kGolden)); 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 // A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
// either is a byte-format break the round-trip alone can't prove (both sides could // blob restores as the silent empty state, not as a set of accidental values.
// drift together). Pins the writer's absolute bytes. 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() { 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); const std::vector<std::uint8_t> bytes = serializeComponentState(in);
CHECK(bytes.size() > 5); CHECK(bytes.size() > 5);
if (bytes.size() > 5) { if (bytes.size() > 5) {
@@ -254,64 +443,479 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono CHECK(bytes[4] == 0); // ChannelMode::Mono
} }
CHECK(kComponentStateVersion == 11); CHECK(kComponentStateVersion == 11);
CHECK(kZonesPayloadVersion == 7); CHECK(kParamsPayloadVersion == 8);
CHECK(kZonesFormatMarker == 0xFFFFFF00u); 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() { static void testV1SelectionLift() {
const std::vector<std::uint8_t> v1 = serializeSelection("old-pick"); const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
const ComponentState out = deserializeComponentState(v1, 48000.0); const ComponentState out = deserializeComponentState(v1, 48000.0);
CHECK(out.selectionId == "old-pick"); CHECK(out.selectionId == "old-pick");
CHECK(out.map.zones.size() == 1); CHECK(!out.params.rootOverride);
if (out.map.zones.size() == 1) { CHECK(out.params.keyTrack == 1.0);
CHECK(out.map.zones[0].sampleId == "old-pick");
CHECK(out.map.zones[0].lowNote == 0);
CHECK(out.map.zones[0].highNote == 127);
}
} }
// 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() { static void testTruncationDegradesCleanly() {
ComponentState in; ComponentState in;
in.selectionId = "smp-2"; in.selectionId = "smp-2";
PerformanceZone z; in.params.rootOverride = 61;
z.sampleId = "smp-2"; in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
in.map.zones.push_back(z); in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
const std::vector<std::uint8_t> bytes = serializeComponentState(in); const std::vector<std::uint8_t> current = 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);
}
// serializePerformance/deserializePerformance round-trip through the v2 envelope. legacy::Zone z;
static void testPerformanceRoundTrip() { z.sampleId = "smp-2";
PerformanceMap in; const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);
PerformanceZone z;
z.sampleId = "zone-a"; for (const std::vector<std::uint8_t>* blob : {&current, &retired}) {
z.lowNote = 10; for (std::size_t cut = 0; cut < blob->size(); ++cut) {
z.highNote = 20; const std::vector<std::uint8_t> part(blob->begin(),
in.zones.push_back(z); blob->begin() + static_cast<long>(cut));
const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0); const ComponentState out = deserializeComponentState(part, 48000.0);
CHECK(out.zones.size() == 1); CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
if (out.zones.size() == 1) { }
CHECK(out.zones[0].sampleId == "zone-a");
CHECK(out.zones[0].lowNote == 10);
CHECK(out.zones[0].highNote == 20);
} }
} }
int main() { int main() {
testComponentStateRoundTrip(); testComponentStateRoundTrip();
testGoldenFullBlobFixture(); testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen(); testEnvelopePrefixBytesFrozen();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testSingleZoneMigrationLiftsLoopDisablingOverride();
testLiftedStateReSavesInCurrentFormat();
testMultiZoneMigrationAdoptsFirstZone();
testFirstZoneSupersedesStoredSelection();
testEmptyZoneListKeepsTheStoredSelection();
testEveryOlderPayloadVersionMigrates();
testLegacyV3FramesConvertAtTheProjectRate();
testEnvelopeLadderLiftsEachVersion();
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
testCorruptFieldsFallBackToDefaults();
testSampleRefsReaderRangeFallbacks();
testSampleRefsTruncatedMidEntry();
testWriterClampsOutOfRangeFields();
testUnknownEnvelopeVersionIsEmpty();
testV1SelectionLift(); testV1SelectionLift();
testTruncationDegradesCleanly(); testTruncationDegradesCleanly();
testPerformanceRoundTrip();
if (failures == 0) { if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n"); std::printf("component_state_io_tests: all tests passed\n");
return 0; 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. // 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 // Same fast assert loop as the sibling pure tests: assert the embedded TCP/MCP strip's
// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. // layout math + key-span mapping + level fill directly.
// //
// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area // 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); // (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, // keySpanRect mapping the 128-key span linearly, tiling adjacent spans seamlessly,
// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match // resolving a single-key span (the root marker), and clamping out-of-range/inverted notes;
// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list;
// levelFillRect clamping 0..1 and its endpoints. // levelFillRect clamping 0..1 and its endpoints.
#include "../src/core/instrument/ui/embed_strip.h" #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); CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y);
} }
// --- zoneSegmentRect ---------------------------------------------------------- // --- keySpanRect --------------------------------------------------------------
static void testZoneSegmentFullSpan() { static void testKeySpanFullKeyboard() {
// A zone covering the whole keyboard spans the entire keymap band width. // The loaded capture responds across the whole keyboard, so its span is the whole band.
const EmbedLayout L = layoutEmbed(256, 40); 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.x == L.keymap.x);
CHECK(r.right() == L.keymap.right()); CHECK(r.right() == L.keymap.right());
CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom()); CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom());
} }
static void testAdjacentZonesTileSeamlessly() { static void testAdjacentSpansTileSeamlessly() {
// 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or // 256px band, 128 keys -> 2px/key. Spans 0..59 and 60..127 must abut with no gap or
// overlap: the low zone's right == the high zone's left. // overlap: the low span's right == the high span's left.
const EmbedLayout L = layoutEmbed(256, 40); const EmbedLayout L = layoutEmbed(256, 40);
const Rect lo = zoneSegmentRect(L, 0, 59); const Rect lo = keySpanRect(L, 0, 59);
const Rect hi = zoneSegmentRect(L, 60, 127); const Rect hi = keySpanRect(L, 60, 127);
CHECK(lo.x == L.keymap.x); CHECK(lo.x == L.keymap.x);
CHECK(hi.right() == L.keymap.right()); CHECK(hi.right() == L.keymap.right());
CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion
CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px 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); 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. // 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()); 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); 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 ------------------------------------------------------------ // --- levelFillRect ------------------------------------------------------------
static void testLevelFillClamps() { static void testLevelFillClamps() {
@@ -144,12 +117,10 @@ int main() {
testLayoutNormalArea(); testLayoutNormalArea();
testLayoutTinyAreaKeepsKeymap(); testLayoutTinyAreaKeepsKeymap();
testLayoutZeroArea(); testLayoutZeroArea();
testZoneSegmentFullSpan(); testKeySpanFullKeyboard();
testAdjacentZonesTileSeamlessly(); testAdjacentSpansTileSeamlessly();
testZoneSegmentClampsBadNotes(); testSingleKeySpanIsTheRootMarker();
testZoneAtPointHits(); testKeySpanClampsBadNotes();
testZoneAtPointFirstMatchOnOverlap();
testZoneAtPointMisses();
testLevelFillClamps(); testLevelFillClamps();
if (g_fail == 0) std::printf("embed_strip: all tests passed\n"); 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); const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId == id); // the capture IS selected — the whole point 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.channelMode == ChannelMode::Mono); // fresh-instance default
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign 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 CHECK(!p.compChunk.empty()); // still a versioned envelope, just an empty selection
const ComponentState cs = deserializeComponentState(p.compChunk, kRate); const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
CHECK(cs.selectionId.empty()); 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). // 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. // 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 // Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip
// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta // layout, root marker, key mapping, and drag-delta note resolver directly — the geometry
// note resolver directly — the geometry that backs the single-capture root-set and the opt-in // that backs the root display and root-set.
// Zones panel.
// //
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the // Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect); // boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning // keyAtPoint inverting the mapping and clamping/ missing off-band; resolveDragNote rounding
// [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
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width // 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 // 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]. // 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 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 ---------------------------------------------------------- // --- resolveDragNote ----------------------------------------------------------
static void testResolveDragRoundsToNearestKey() { static void testResolveDragRoundsToNearestKey() {
@@ -246,12 +179,6 @@ int main() {
testRootMarkerEqualsKeyRect(); testRootMarkerEqualsKeyRect();
testKeyAtPointInverts(); testKeyAtPointInverts();
testKeyAtPointOffBand(); testKeyAtPointOffBand();
testZoneBarSpansInclusive();
testZoneBarMalformedCollapses();
testZoneGrabEdgesAndBody();
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
testZoneBarAtPointFirstMatch();
testZoneBarAtPointNullList();
testResolveDragRoundsToNearestKey(); testResolveDragRoundsToNearestKey();
testResolveDragClampsAndNoOps(); testResolveDragClampsAndNoOps();
testResolveDragProportionalNonDivisibleWidth(); 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 + // by the CMake target linking neither SDK — this file includes only sampler_core.h +
// the standard library, which is itself the compile-time proof. // 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 <algorithm>
#include <cmath> #include <cmath>
@@ -69,49 +69,42 @@ static AdsrParams flatAdsr() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 6. Keymap resolution. // 6. Full-keyboard response over the one loaded capture.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static void testChromaticSingleRoot() { static void testEveryKeyPlaysTheLoadedCapture() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // No key range survives: the loaded capture answers every note in 0..127, repitched
CHECK(km.zones.size() == 1); // from its root. Each note-on must take a real voice.
// Every note in 0..127 resolves to the single zone. SampleData km = dcSample(100, 60);
VoiceEngine eng(128, km);
for (int n = 0; n <= 127; ++n) { for (int n = 0; n <= 127; ++n) {
ZoneResolution r = km.resolve(n, 100); CHECK(eng.noteOn(n, 100) != VoiceEngine::kNoVoice);
CHECK(r.matched);
CHECK(r.zoneIndex == 0);
} }
CHECK(eng.activeVoiceCount() == 128);
} }
static void testZonedRangesBoundaries() { static void testUnplayableCaptureRefusesEveryNote() {
Keymap km; // Nothing decoded -> the defined no-play at every key, in both voice modes, rather
km.samples.push_back(dcSample(100, 48)); // low sample // than a voice started on an empty read span.
km.samples.push_back(dcSample(100, 72)); // high sample SampleData empty; // no frames
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the VoiceEngine poly(4, empty);
// correct zone; a first-match order test would catch an off-by-one. CHECK(poly.noteOn(60, 100) == VoiceEngine::kNoVoice);
km.zones.push_back(KeyZone{36, 59, 48, 0}); CHECK(poly.noteOn(0, 100) == VoiceEngine::kNoVoice);
km.zones.push_back(KeyZone{60, 83, 72, 1}); CHECK(poly.activeVoiceCount() == 0);
CHECK(km.resolve(36, 100).matched); VoiceEngine mono(4, empty, 0, 0, VoiceMode::Mono);
CHECK(km.resolve(36, 100).zoneIndex == 0); CHECK(mono.noteOn(60, 100) == VoiceEngine::kNoVoice);
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0 CHECK(mono.activeVoiceCount() == 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);
} }
static void testFirstMatchOnOverlap() { static void testOutOfRangeNotesAreRefusedInMono() {
// Overlapping zones: the earlier zone wins (documented deterministic rule). // The mono held stack keys notes as uint8, so an out-of-range note must be rejected
Keymap km; // BEFORE it can alias onto a real held note.
km.samples.push_back(dcSample(10, 60)); SampleData km = dcSample(100, 60);
km.samples.push_back(dcSample(10, 60)); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono);
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first CHECK(eng.noteOn(-1, 100) == VoiceEngine::kNoVoice);
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all CHECK(eng.noteOn(128, 100) == VoiceEngine::kNoVoice);
CHECK(km.resolve(60, 100).zoneIndex == 0); CHECK(eng.activeVoiceCount() == 0);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -178,7 +171,7 @@ static void testRepitchObservedPeriod() {
// Unity: played at root, observed period ~= native. // 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); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -188,7 +181,7 @@ static void testRepitchObservedPeriod() {
} }
// +1 octave: advances 2x, observed period halves. // +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); VoiceEngine eng(4, km);
eng.noteOn(72, 127); eng.noteOn(72, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -198,7 +191,7 @@ static void testRepitchObservedPeriod() {
} }
// -1 octave: advances 0.5x, observed period doubles. // -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); VoiceEngine eng(4, km);
eng.noteOn(48, 127); eng.noteOn(48, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -217,9 +210,9 @@ static void testKeyTrackVarispeedObservedPeriod() {
auto periodAt = [&](int note, double keyTrack) -> double { auto periodAt = [&](int note, double keyTrack) -> double {
SampleData s = sineSample(frames, cycles, 60); SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Varispeed; 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. // 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); VoiceEngine eng(4, km);
eng.noteOn(note, 127); eng.noteOn(note, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -247,8 +240,8 @@ static void testKeyTrackPreserveShiftCollapsesAtZero() {
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> { auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
SampleData s = sineSample(frames, cycles, 60); SampleData s = sineSample(frames, cycles, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
km.zones[0].keyTrack = keyTrack; km.keyTrack = keyTrack;
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window)); VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
eng.noteOn(note, 127); eng.noteOn(note, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -375,7 +368,7 @@ static void testAdsrZeroAttackDecay() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static void testPolyphonicAllocation() { static void testPolyphonicAllocation() {
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60)); SampleData km = (dcSample(1000, 60));
VoiceEngine eng(8, km); VoiceEngine eng(8, km);
// Four simultaneous notes -> four active voices, each on a distinct voice. // Four simultaneous notes -> four active voices, each on a distinct voice.
@@ -423,11 +416,11 @@ static void testNoteOffReleasesNewestSameNote() {
SampleData sd = dcSample(100000, 60); SampleData sd = dcSample(100000, 60);
sd.play.adsr = flatAdsr(); sd.play.adsr = flatAdsr();
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release 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 // 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 // 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. // 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); VoiceEngine eng(8, km);
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
@@ -463,17 +456,6 @@ static void testNoteOffReleasesNewestSameNote() {
CHECK(eng.activeVoiceCount() == 0); 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. // 2. Voice stealing at the bound.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -484,7 +466,7 @@ static void testStealsReleasingVoiceFirst() {
SampleData s = dcSample(100000, 60); SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active" 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); VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 std::size_t vA = eng.noteOn(60, 100); // startOrder 1
@@ -509,7 +491,7 @@ static void testStealsOldestWhenNoneReleasing() {
SampleData s = dcSample(100000, 60); SampleData s = dcSample(100000, 60);
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = 100000; s.play.adsr.releaseFrames = 100000;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(2, km); VoiceEngine eng(2, km);
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest) std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
@@ -547,7 +529,7 @@ static void testLoopSustainSeamless() {
s.loop.start = 20; s.loop.start = 20;
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity eng.noteOn(60, 127); // unity ratio, full velocity
@@ -569,7 +551,7 @@ static void testZeroLengthLoopGoesSilent() {
s.loop.hasLoop = true; s.loop.hasLoop = true;
s.loop.start = 25; s.loop.start = 25;
s.loop.end = 25; // zero length s.loop.end = 25; // zero length
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
@@ -593,7 +575,7 @@ static void testSingleFrameLoop() {
s.loop.start = 5; s.loop.start = 5;
s.loop.end = 6; // single-frame loop: [5, 6) 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity 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). // No loop at all: held note runs off the end and goes idle (same as zero-length).
SampleData s = dcSample(50, 60); SampleData s = dcSample(50, 60);
// s.loop.hasLoop stays false. // s.loop.hasLoop stays false.
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; 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; for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60; s.rootNote = 60;
s.startFrame = 30; s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -649,7 +631,7 @@ static void testStartFrameZeroIsUnchanged() {
s.frames.resize(20); s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f; for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0 s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -662,7 +644,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
// out-of-bounds read that would start the voice already exhausted. // out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0 SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -683,7 +665,7 @@ static void testStartFrameWithLoop() {
s.loop.hasLoop = true; s.loop.hasLoop = true;
s.loop.start = 20; s.loop.start = 20;
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -712,7 +694,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
s.loop.start = 20; s.loop.start = 20;
s.loop.end = 40; s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity eng.noteOn(60, 127); // unity ratio, full velocity
@@ -734,11 +716,11 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
// velocity -> volume. // 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 // 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. // builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
static void testVelocityDefaultCurveIsFlatUnity() { 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}) { for (int vel : {1, 64, 100, 127}) {
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, vel); 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 // 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. // (not a hardcoded map) drives the gain, and that eval is applied at note-on.
static void testVelocityLinearCurveReproducesRamp() { static void testVelocityLinearCurveReproducesRamp() {
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0 SampleData km = (dcSample(100, 60)); // DC 1.0
km.zones[0].velocityCurve = VelocityCurve::linear(); km.velocityCurve = VelocityCurve::linear();
{ {
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); 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 // 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. // curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
static void testVelocityShapedCurveDrivesGain() { 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(); VelocityCurve curve = VelocityCurve::linear();
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9 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); VoiceEngine eng(1, km);
eng.noteOn(60, 64); eng.noteOn(60, 64);
std::vector<AudioSample> out; eng.render(out, 1); std::vector<AudioSample> out; eng.render(out, 1);
@@ -790,7 +772,7 @@ static void testVelocityShapedCurveDrivesGain() {
// Two voices summed: polyphony mixes additively. // Two voices summed: polyphony mixes additively.
static void testPolyphonyMixesAdditively() { 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); VoiceEngine eng(4, km);
eng.noteOn(60, 127); // gain 1.0 eng.noteOn(60, 127); // gain 1.0
eng.noteOn(60, 127); // gain 1.0 (second voice, same note) eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
@@ -826,7 +808,7 @@ static void testChannelCount() {
static void testStereoRenderKeepsChannelsDistinct() { static void testStereoRenderKeepsChannelsDistinct() {
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each // 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. // 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
@@ -841,7 +823,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
static void testMonoSamplePlaysDualMonoInStereo() { static void testMonoSamplePlaysDualMonoInStereo() {
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal // 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. // (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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> left(8, 0.f), right(8, 0.f); 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); SampleData s = sineSample(600, 12.0, 60);
s.framesR = s.frames; // dual-mono: identical channels s.framesR = s.frames; // dual-mono: identical channels
s.play.pitchEngine = engine; s.play.pitchEngine = engine;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128); VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
eng.noteOn(note, 127); eng.noteOn(note, 127);
std::vector<AudioSample> left(256, 0.f), right(256, 0.f); std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
@@ -884,7 +866,7 @@ static void testDualMonoStereoSampleRendersCentered() {
static void testMonoRenderUnchangedByStereoData() { static void testMonoRenderUnchangedByStereoData() {
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical // 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. // 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); VoiceEngine engS(1, kmS);
engS.noteOn(60, 127); engS.noteOn(60, 127);
std::vector<AudioSample> mono; std::vector<AudioSample> mono;
@@ -909,7 +891,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
s.framesR[i] = v; s.framesR[i] = v;
} }
s.rootNote = 60; s.rootNote = 60;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(4, km); VoiceEngine eng(4, km);
eng.noteOn(72, 127); // +1 octave eng.noteOn(72, 127); // +1 octave
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f); std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
@@ -920,7 +902,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
static void testStereoRenderSumsVoicesPerChannel() { static void testStereoRenderSumsVoicesPerChannel() {
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo). // 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); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
eng.noteOn(60, 127); // second voice, same note eng.noteOn(60, 127); // second voice, same note
@@ -931,7 +913,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
} }
static void testStereoRenderNullBufferIsNoOp() { 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> buf(4, 0.f); std::vector<AudioSample> buf(4, 0.f);
@@ -960,7 +942,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
s.loop.start = 20; s.loop.start = 20;
s.loop.end = 30; // loop [20,30): frames 20..29 s.loop.end = 30; // loop [20,30): frames 20..29
CHECK(s.channelCount() == 2); CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain 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. --- // --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
static void testTriggerLengthFractionFrames() { static void testTriggerLengthFractionFrames() {
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees. // 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity ratio eng.noteOn(60, 127); // unity ratio
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1072,7 +1054,7 @@ static void testTriggerLengthFractionFrames() {
// --- Trigger start point: %-length measured from the start offset. --- // --- Trigger start point: %-length measured from the start offset. ---
static void testTriggerLengthWithStart() { static void testTriggerLengthWithStart() {
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free. // 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1086,7 +1068,7 @@ static void testTriggerLengthWithStart() {
static void testTriggerFadeShape() { static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity // 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. // 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1106,7 +1088,7 @@ static void testTriggerFadeShape() {
static void testTriggerEdgeCases() { static void testTriggerEdgeCases() {
// %=0: zero play length -> voice frees at once, no sound. // %=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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; 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]). // 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. // 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1127,7 +1109,7 @@ static void testTriggerEdgeCases() {
} }
// %=100 plays the full post-start span. // %=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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1139,7 +1121,7 @@ static void testTriggerEdgeCases() {
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. --- // --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
static void testTriggerIgnoresNoteOff() { 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1189,7 +1171,7 @@ static void testPreserveDurationInvariance() {
const std::size_t window = 512; // pre-size the shifters const std::size_t window = 512; // pre-size the shifters
auto lengthAt = [&](int note) -> std::size_t { 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)); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
eng.noteOn(note, 127); eng.noteOn(note, 127);
return soundingLength(eng, 4000); return soundingLength(eng, 4000);
@@ -1216,7 +1198,7 @@ static void testVarispeedStillCouplesDuration() {
s.play.playMode = PlayMode::Trigger; s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEngine = PitchEngine::Varispeed;
s.play.trigger.lengthFraction = 1.0; s.play.trigger.lengthFraction = 1.0;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(note, 127); eng.noteOn(note, 127);
return soundingLength(eng, 4000); return soundingLength(eng, 4000);
@@ -1241,7 +1223,7 @@ static void testPitchEnvOffBitIdentical() {
s.play.pitchEnv.attackFrames = 0; s.play.pitchEnv.attackFrames = 0;
s.play.pitchEnv.decayFrames = 500; s.play.pitchEnv.decayFrames = 500;
} }
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path) eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1269,7 +1251,7 @@ static void testPitchEnvOnBendsVarispeed() {
s.play.pitchEnv.attackFrames = 0; // start at the peak s.play.pitchEnv.attackFrames = 0; // start at the peak
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1304,7 +1286,7 @@ static void testPreserveGateStereoLoopComposes() {
s.play.playMode = PlayMode::Gate; s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
CHECK(s.channelCount() == 2); CHECK(s.channelCount() == 2);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km, 0, 512); VoiceEngine eng(1, km, 0, 512);
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held) eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f); std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
@@ -1337,7 +1319,7 @@ static void testPreserveGateStereoLoopComposes() {
static void testPreserveVoiceCap() { static void testPreserveVoiceCap() {
SampleData s = dcSample(2000, 60); SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough) 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. // 8 voices total, Preserve cap of 2.
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256); VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
@@ -1362,7 +1344,7 @@ static void testPreserveUnityEngineVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60); SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack 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); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(note, 127); eng.noteOn(note, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1394,7 +1376,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
SampleData s = dcSample(4000, 60); SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack 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); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1413,7 +1395,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
static void testPreserveUnityVoiceCountsTowardCap() { static void testPreserveUnityVoiceCountsTowardCap() {
SampleData s = dcSample(2000, 60); SampleData s = dcSample(2000, 60);
s.play.pitchEngine = PitchEngine::Preserve; 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); VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap) CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
@@ -1429,8 +1411,8 @@ static void testVelocityCurveAppliesUnderPreserve() {
auto steadyLevelAt = [&](int vel) -> double { auto steadyLevelAt = [&](int vel) -> double {
SampleData s = dcSample(4000, 60); SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear(); km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256); VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion) eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1460,7 +1442,7 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 0; s.play.adsr.releaseFrames = 0;
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0 eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1483,7 +1465,7 @@ static void testZeroAdsrIsInstantSustain() {
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp. // Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
s.play.adsr = AdsrParams{}; s.play.adsr = AdsrParams{};
s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEngine = PitchEngine::Varispeed;
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(1, km); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1507,17 +1489,20 @@ static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
return s; return s;
} }
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone // The mono tests need to read WHICH NOTE holds the single voice off the rendered value, and
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B = // a DC sample makes pitch inaudible. Velocity is the discriminator: a DC 1.0 capture with a
// notes [60,80] root 70 -> 0.75. // curve pinned through two probe velocities renders 0.25 for a kVelLow strike and 0.75 for a
static Keymap twoLevelKeymap() { // kVelHigh one (the Hermite spline passes exactly through its control points). Each test
Keymap km; // then presses note 50 soft and note 70 hard, so the level names the sounding note.
km.samples.push_back(dcLevelSample(200000, 0.25f, 50)); static constexpr int kVelLow = 32;
km.samples.push_back(dcLevelSample(200000, 0.75f, 70)); static constexpr int kVelHigh = 96;
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; static SampleData twoLevelSample() {
km.zones.push_back(a); SampleData km = dcLevelSample(200000, 1.0f, 60);
km.zones.push_back(b); 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; 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 // back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
// ONE voice regardless of the pool size. // ONE voice regardless of the pool size.
static void testMonoLastNotePriorityAndFallback() { static void testMonoLastNotePriorityAndFallback() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); 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(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(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // top released -> FALLBACK to still-held 50 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 // 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. // note also leaves the stack, so the final note-off truly empties it.
static void testMonoReleaseOfLowerHeldNoteIsInaudible() { static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); eng.noteOn(50, kVelLow);
eng.noteOn(70, 127); // 70 sounds, 50 held beneath eng.noteOn(70, kVelHigh); // 70 sounds, 50 held beneath
eng.noteOff(50); // releasing the buried note: inaudible eng.noteOff(50); // releasing the buried note: inaudible
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // 50 already left the stack -> silence, no fallback 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 // Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
// beneath becomes the fallback. // beneath becomes the fallback.
static void testMonoRepressHeldNoteMovesToTop() { static void testMonoRepressHeldNoteMovesToTop() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); eng.noteOn(50, kVelLow);
eng.noteOn(70, 127); eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top CHECK(eng.noteOn(50, kVelLow) == 0); // re-press while held: back on top
CHECK(approx(probeFrame(eng), 0.25, 1e-6)); CHECK(approx(probeFrame(eng), 0.25, 1e-6));
eng.noteOff(50); // falls back to 70 (now the most recent held) eng.noteOff(50); // falls back to 70 (now the most recent held)
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); 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. // held note on the stack), not the departing note's.
static void testMonoRetriggerFallbackUsesOriginalVelocity() { static void testMonoRetriggerFallbackUsesOriginalVelocity() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127 km.velocityCurve = VelocityCurve::linear(); // gain = velocity/127
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 32); // soft first note eng.noteOn(60, 32); // soft first note
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4)); 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)); 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 // 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. // stack (so it can never take the voice back on a fallback), and its note-off is inert. The
static void testMonoOutOfZoneNeverJoinsStack() { // stack keys notes as uint8, so an unguarded 200 would alias onto 72 and corrupt it.
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only static void testMonoOutOfRangeNeverJoinsStack() {
SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(70, 127); eng.noteOn(70, kVelHigh);
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone CHECK(eng.noteOn(200, 127) == VoiceEngine::kNoVoice); // past the MIDI range
CHECK(eng.activeVoiceCount() == 1); CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed 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)); CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1609,7 +1595,7 @@ static void testMonoOutOfZoneNeverJoinsStack() {
static void testMonoRetriggerRestartsEnvelope() { static void testMonoRetriggerRestartsEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1623,7 +1609,7 @@ static void testMonoRetriggerRestartsEnvelope() {
static void testMonoLegatoContinuesEnvelope() { static void testMonoLegatoContinuesEnvelope() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1645,8 +1631,8 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
} }
s.rootNote = 60; s.rootNote = 60;
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
km.zones[0].velocityCurve = VelocityCurve::linear(); km.velocityCurve = VelocityCurve::linear();
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
std::vector<AudioSample> out; 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) 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 // LEGATO takeover ALWAYS glides now: with one loaded capture there is no second PCM stream
// sample restarts the voice (one read head cannot glide between two PCM streams). // to cross into, so the read head never has to restart mid-phrase. (The retired
static void testMonoLegatoCrossSampleRestarts() { // cross-sample-restart branch was the multi-zone case.)
Keymap km = twoLevelKeymap(); static void testMonoLegatoAlwaysGlidesWithinThePhrase() {
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once eng.noteOn(50, kVelLow);
CHECK(approx(probeFrame(eng), 0.25, 1e-6)); std::vector<AudioSample> out;
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune eng.render(out, 50); // mid-attack: level ~0.49 * the 0.25 vel gain
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over 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 // 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); SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.adsr.attackFrames = 100; s.play.adsr.attackFrames = 100;
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1692,7 +1683,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
static void testMonoIgnoresPreserveCap() { static void testMonoIgnoresPreserveCap() {
SampleData s = dcSample(4000, 60); SampleData s = dcSample(4000, 60);
s.play.pitchEngine = PitchEngine::Preserve; 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, VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
VoiceMode::Mono, MonoTrigger::Retrigger); VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap 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() { static void testMonoLegatoTriggerReattacksAfterKeyUp() {
SampleData s = rampSample(200000, 60); SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // unity: read advances 1/frame eng.noteOn(60, 127); // unity: read advances 1/frame
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1739,7 +1730,7 @@ static void testMonoLegatoTriggerReattacksAfterKeyUp() {
static void testMonoLegatoTriggerHeldKeyStillRetunes() { static void testMonoLegatoTriggerHeldKeyStillRetunes() {
SampleData s = rampSample(200000, 60); SampleData s = rampSample(200000, 60);
s.play.playMode = PlayMode::Trigger; 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); eng.noteOn(60, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -1751,7 +1742,7 @@ static void testMonoLegatoTriggerHeldKeyStillRetunes() {
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence). // MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
static void testAllNotesOffReleasesPolyVoices() { static void testAllNotesOffReleasesPolyVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(4, km); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
eng.noteOn(62, 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 // 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. // (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
static void testAllNotesOffClearsMonoHeldStack() { static void testAllNotesOffClearsMonoHeldStack() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom) eng.noteOn(50, kVelLow); // 50's note-off will never arrive (phantom)
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack eng.noteOn(70, kVelHigh); // 70 sounds, phantom 50 buried on the stack
eng.allNotesOff(); // PANIC eng.allNotesOff(); // PANIC
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); CHECK(approx(probeFrame(eng), 0.0, 1e-9));
CHECK(eng.activeVoiceCount() == 0); CHECK(eng.activeVoiceCount() == 0);
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback // 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). // 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)); CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); eng.noteOff(70);
CHECK(approx(probeFrame(eng), 0.0, 1e-9)); CHECK(approx(probeFrame(eng), 0.0, 1e-9));
@@ -1790,7 +1781,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
s.play.playMode = PlayMode::Trigger; s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames 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); VoiceEngine eng(1, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
CHECK(eng.activeVoiceCount() == 1); CHECK(eng.activeVoiceCount() == 1);
@@ -1812,7 +1803,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
static void testAllNotesOffStillReleasesGateVoices() { static void testAllNotesOffStillReleasesGateVoices() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
// Default Gate mode, instant release (releaseFrames 0). // Default Gate mode, instant release (releaseFrames 0).
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(4, km); VoiceEngine eng(4, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
eng.noteOn(62, 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. // rather than retune. This is the correct fresh-phrase behavior documented in the comment.
static void testMonoLegatoSameNoteRepressReattacks() { static void testMonoLegatoSameNoteRepressReattacks() {
SampleData s = rampSample(200000, 60); 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
eng.noteOn(60, 127); // first press; read starts at 0 eng.noteOn(60, 127); // first press; read starts at 0
std::vector<AudioSample> out; 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. // losing its fallback. Note-ons out of [0,127] are a defined no-play.
static void testMonoOutOfRangeNotesRejected() { static void testMonoOutOfRangeNotesRejected() {
SampleData s = dcLevelSample(200000, 1.0f, 60); 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); VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice); CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
CHECK(eng.noteOn(-1, 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. // notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
static void testVoiceCountBoundsPolyphony() { static void testVoiceCountBoundsPolyphony() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine e3(3, km); VoiceEngine e3(3, km);
CHECK(e3.maxVoices() == 3); CHECK(e3.maxVoices() == 3);
e3.noteOn(60, 127); 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.attackFrames = 100; // real attack: the new tone starts near 0
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -1930,7 +1921,7 @@ static void testMonoRetrigFallbackDeclicksRestart() {
s.play.adsr.attackFrames = 100; s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -1965,7 +1956,7 @@ static void testMonoDeclickOnlyOnTakeover() {
s.play.adsr.attackFrames = 100; s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -1993,7 +1984,7 @@ static void testPolyStealDeclicksRestart() {
s.play.adsr.attackFrames = 100; s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2026,7 +2017,7 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
s.play.adsr.attackFrames = 100; s.play.adsr.attackFrames = 100;
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*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.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2107,7 +2098,7 @@ static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& pos
static void testMonoRetrigTriggerZoneDeclicksRestart() { static void testMonoRetrigTriggerZoneDeclicksRestart() {
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame 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 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2134,7 +2125,7 @@ static void testZeroAttackGateRetrigNoStep() {
s.play.adsr.attackFrames = 0; // instant-unity attack s.play.adsr.attackFrames = 0; // instant-unity attack
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2158,7 +2149,7 @@ static void testZeroAttackGateRetrigNoStep() {
// preview's exact shape (same note, root, full pool of 1). // preview's exact shape (same note, root, full pool of 1).
static void testPreviewReauditionDeclicksViaEngineSteal() { static void testPreviewReauditionDeclicksViaEngineSteal() {
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case) 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2186,7 +2177,7 @@ static void testOverCapChordStealsExactlyOne() {
s.play.adsr.sustainLevel = 1.0; s.play.adsr.sustainLevel = 1.0;
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
s.play.pitchEngine = PitchEngine::Preserve; 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. // Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400); 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. // path. This is the processor's mailbox-drain contract, pinned in the pure core.
static void testPreviewNoteObeysVoicing() { static void testPreviewNoteObeysVoicing() {
SampleData s = dcLevelSample(200000, 1.0f, 60); SampleData s = dcLevelSample(200000, 1.0f, 60);
Keymap km = Keymap::singleSampleChromatic(std::move(s)); SampleData km = (std::move(s));
VoiceEngine eng(2, km); VoiceEngine eng(2, km);
eng.noteOn(60, 127); eng.noteOn(60, 127);
eng.noteOn(62, 127); // the pool is now FULL 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 the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
// pins it for Poly steal. // pins it for Poly steal.
static void testPreviewNoteJoinsMonoHeldStack() { static void testPreviewNoteJoinsMonoHeldStack() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger); 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(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(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); CHECK(approx(probeFrame(eng), 0.75, 1e-6));
eng.noteOff(70); // preview release: FALLBACK to the held note 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 // the drain engine must release its voice (otherwise the old-snapshot preview would
// sustain until the next reload hard-cut it). // sustain until the next reload hard-cut it).
static void testPreviewNoteOffRoutesToDrainEngine() { static void testPreviewNoteOffRoutesToDrainEngine() {
Keymap km = twoLevelKeymap(); SampleData km = twoLevelSample();
VoiceEngine drainEng(2, km); // was live when the preview fired VoiceEngine drainEng(2, km); // was live when the preview fired
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices 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(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
CHECK(liveEng.activeVoiceCount() == 0); CHECK(liveEng.activeVoiceCount() == 0);
// The preview release, drained to BOTH engines like a host note-off: // 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 const double kCycles = 6000.0; // period = 8 frames
SampleData s = sineSample(kFrames, kCycles, 60); SampleData s = sineSample(kFrames, kCycles, 60);
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case) 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, VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
/*takeoverDeclick=*/true); /*takeoverDeclick=*/true);
@@ -2402,7 +2393,7 @@ static void testPreserveTailFinalWindowGapFree() {
SampleData s = tailSine(frames, f0, 60); SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end 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) 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)); VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(note, 127); eng.noteOn(note, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -2430,7 +2421,7 @@ static void testPreserveTailReleaseContinuous() {
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); s.play.adsr = flatAdsr();
s.play.adsr.releaseFrames = static_cast<std::int64_t>(w); // release spans the final window 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)); VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127); eng.noteOn(67, 127);
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -2462,7 +2453,7 @@ static void testPreserveTriggerTailGapFree() {
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.playMode = PlayMode::Trigger; s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero) 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)); VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
eng.noteOn(67, 127); eng.noteOn(67, 127);
const std::size_t playEnd = 6554; // round(0.8 * 8192) const std::size_t playEnd = 6554; // round(0.8 * 8192)
@@ -2497,7 +2488,7 @@ static void testPreservePrimeStopsAtTriggerPlayEnd() {
s.play.playMode = PlayMode::Trigger; s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000 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)); 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 eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
// the deepest primed history the ring holds // the deepest primed history the ring holds
@@ -2525,7 +2516,7 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
SampleData s = tailSine(frames, f0, 60); SampleData s = tailSine(frames, f0, 60);
s.play.pitchEngine = PitchEngine::Preserve; s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr = flatAdsr(); 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)); 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) eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
std::vector<AudioSample> out; std::vector<AudioSample> out;
@@ -2538,9 +2529,9 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
} }
int main() { int main() {
testChromaticSingleRoot(); testEveryKeyPlaysTheLoadedCapture();
testZonedRangesBoundaries(); testUnplayableCaptureRefusesEveryNote();
testFirstMatchOnOverlap(); testOutOfRangeNotesAreRefusedInMono();
testPitchRatioMath(); testPitchRatioMath();
testKeyTrackedRatioMath(); testKeyTrackedRatioMath();
testRepitchObservedPeriod(); testRepitchObservedPeriod();
@@ -2551,7 +2542,6 @@ int main() {
testAdsrZeroAttackDecay(); testAdsrZeroAttackDecay();
testPolyphonicAllocation(); testPolyphonicAllocation();
testNoteOffReleasesNewestSameNote(); testNoteOffReleasesNewestSameNote();
testOutOfZoneNoteConsumesNoVoice();
testStealsReleasingVoiceFirst(); testStealsReleasingVoiceFirst();
testStealsOldestWhenNoneReleasing(); testStealsOldestWhenNoneReleasing();
testLoopSustainSeamless(); testLoopSustainSeamless();
@@ -2611,11 +2601,11 @@ int main() {
testMonoReleaseOfLowerHeldNoteIsInaudible(); testMonoReleaseOfLowerHeldNoteIsInaudible();
testMonoRepressHeldNoteMovesToTop(); testMonoRepressHeldNoteMovesToTop();
testMonoRetriggerFallbackUsesOriginalVelocity(); testMonoRetriggerFallbackUsesOriginalVelocity();
testMonoOutOfZoneNeverJoinsStack(); testMonoOutOfRangeNeverJoinsStack();
testMonoRetriggerRestartsEnvelope(); testMonoRetriggerRestartsEnvelope();
testMonoLegatoContinuesEnvelope(); testMonoLegatoContinuesEnvelope();
testMonoLegatoRetunesWithoutReadRestart(); testMonoLegatoRetunesWithoutReadRestart();
testMonoLegatoCrossSampleRestarts(); testMonoLegatoAlwaysGlidesWithinThePhrase();
testMonoLegatoAfterReleaseReattacks(); testMonoLegatoAfterReleaseReattacks();
testMonoIgnoresPreserveCap(); testMonoIgnoresPreserveCap();
testMonoLegatoTriggerReattacksAfterKeyUp(); testMonoLegatoTriggerReattacksAfterKeyUp();