Merge Θ-W1-T1: retire the zone system, re-seam the engine and Sample face into bands
This commit is contained in:
+112
-76
@@ -551,11 +551,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2v) Pure sampler_core library — NO VST3, NO REAPER, NO SWELL. The HEART of the
|
||||
# Phase S MIDI-playback instrument (S3 / D3): polyphonic voice allocation with
|
||||
# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with
|
||||
# (note, velocity) -> zone resolution, and repitch/interpolation from a root note
|
||||
# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book,
|
||||
# tested hard outside any host. Lives under core/instrument/engine/ but
|
||||
# MIDI-playback instrument (D3): polyphonic voice allocation with bounded stealing,
|
||||
# the three envelope evaluators, and repitch/interpolation from a root note with
|
||||
# loop-point-aware sustain over ONE loaded capture. The mirror of bank_model / peaks /
|
||||
# bank_book, tested hard outside any host. Lives under core/instrument/engine/ but
|
||||
# links NEITHER SDK — the plain-data boundary is enforced structurally: the test
|
||||
# target below links only sampler_core (+ its peaks dep for the AudioSample alias,
|
||||
# the one house precedent wav_codec also relies on). The VST3 shell (shell/instrument/
|
||||
@@ -573,12 +572,19 @@ target_link_libraries(pitch_shift PUBLIC peaks)
|
||||
# velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse
|
||||
# map). NO VST3/REAPER/SWELL/vendor and DELIBERATELY no editor_geometry (its hit-test takes an
|
||||
# explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive
|
||||
# dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a
|
||||
# VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
|
||||
# dependency on the editor's layout types. play_params.h carries one (SampleData holds the
|
||||
# curve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
|
||||
add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp)
|
||||
target_include_directories(velocity_curve PUBLIC src)
|
||||
|
||||
add_library(sampler_core STATIC src/core/instrument/engine/sampler_core.cpp)
|
||||
# Two TUs on the engine's own responsibility seam: voice.cpp is the per-NOTE half (note-on
|
||||
# setup, the Preserve ring prime, legato retune), voice_engine.cpp the note routing /
|
||||
# allocation / stealing / mono stack / panic / block render. The per-SAMPLE render half is
|
||||
# inline in voice.h (with the envelope evaluators in envelopes.h) precisely so this TU
|
||||
# boundary costs the hot path nothing — see voice.h's header.
|
||||
add_library(sampler_core STATIC
|
||||
src/core/instrument/engine/voice.cpp
|
||||
src/core/instrument/engine/voice_engine.cpp)
|
||||
target_include_directories(sampler_core PUBLIC src)
|
||||
target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve)
|
||||
|
||||
@@ -817,48 +823,67 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
|
||||
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
|
||||
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge
|
||||
# read marshalling — GetProjExtState result decode + the ONE grow-loop retry
|
||||
# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge
|
||||
# read AND the extension's persist/usage_scan ext-state reads (hence linked into
|
||||
# reaper_reasampler too). Both are unit-tested outside the DAW;
|
||||
# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified.
|
||||
# editor_geometry: the shared geometry vocabulary every instrument UI module speaks
|
||||
# (the `Rect` alias + contains()) — header-only, hence INTERFACE; the Sample face's
|
||||
# own layout lives in sample_bands/sample_chrome below. bridge_marshal: the REAPER
|
||||
# VST-host bridge read marshalling — GetProjExtState result decode + the ONE
|
||||
# grow-loop retry policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the
|
||||
# VST bridge read AND the extension's persist/usage_scan ext-state reads (hence
|
||||
# linked into reaper_reasampler too); unit-tested outside the DAW, while the VST3
|
||||
# shell (shell/instrument/*) that draws/routes/invokes is DAW-verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp)
|
||||
target_include_directories(editor_geometry PUBLIC src)
|
||||
add_library(editor_geometry INTERFACE)
|
||||
target_include_directories(editor_geometry INTERFACE src)
|
||||
|
||||
# sample_bands — THE band-stack allocator: the ONE module that owns the Sample face's
|
||||
# vertical inventory (chrome / two-lane waveform / decks) plus the waveform band's lane
|
||||
# split. A shared READ-ONLY surface for every band owner; a band's interior module lays out
|
||||
# inside the rect it is handed and never re-allocates the stack. NEITHER SDK.
|
||||
add_library(sample_bands STATIC src/core/instrument/ui/sample_bands.cpp)
|
||||
target_include_directories(sample_bands PUBLIC src)
|
||||
target_link_libraries(sample_bands PUBLIC editor_geometry)
|
||||
|
||||
# sample_chrome — the CHROME band's interior: the toolbar row (title + Browse) over the
|
||||
# control row (root strip, preview, velocity knob cell, curve button, channel toggle). Reads
|
||||
# the band rect from sample_bands; owns no vertical inventory of its own. NEITHER SDK.
|
||||
add_library(sample_chrome STATIC src/core/instrument/ui/sample_chrome.cpp)
|
||||
target_include_directories(sample_chrome PUBLIC src)
|
||||
target_link_libraries(sample_chrome PUBLIC sample_bands)
|
||||
|
||||
add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp)
|
||||
target_include_directories(bridge_marshal PUBLIC src)
|
||||
|
||||
# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the
|
||||
# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill.
|
||||
# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside
|
||||
# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed
|
||||
# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect.
|
||||
# embed_strip (Phase S6) — PURE layout for the embedded TCP/MCP strip: the 128-key span ->
|
||||
# key-span rects (the loaded capture's full span, and its root marker) and the level-band
|
||||
# fill. Read-only, so no hit-test. Unit-tested outside the DAW, while the embed shell
|
||||
# (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed messages (the paint
|
||||
# bitmap) into it. Links editor_geometry for the shared Rect.
|
||||
add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp)
|
||||
target_include_directories(embed_strip PUBLIC src)
|
||||
target_link_libraries(embed_strip PUBLIC editor_geometry)
|
||||
|
||||
# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the
|
||||
# instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse,
|
||||
# NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned
|
||||
# keymap builds, and the refs/performance resolution. Links the three pure modules it
|
||||
# composes — bank_book (shared JSON), wav_codec (shared WAV parse), and sampler_core (the
|
||||
# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor)
|
||||
# does the bridge read + file I/O off the audio thread, then calls these; the process
|
||||
# callback stays allocation-free. The ComponentState codec is component_state_io below.
|
||||
# NOT a second parser), interleaved->mono downmix (the channel policy), the one parameter
|
||||
# set's override-beats-intrinsic fold, and the SampleData build. Links the pure modules it
|
||||
# composes — bank_book (shared JSON), wav_codec (shared WAV parse), velocity_curve (the
|
||||
# curve field play_params.h carries) — and NEITHER SDK. NOT the voice engine: since the
|
||||
# build's product is plain SampleData, the engine's object code is no longer a dependency.
|
||||
# The VST3 shell (reasampler_processor) does the bridge read + file I/O off the audio
|
||||
# thread, then calls these; the process callback stays allocation-free. The ComponentState
|
||||
# codec is component_state_io below.
|
||||
add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp)
|
||||
target_include_directories(sample_map PUBLIC src)
|
||||
target_link_libraries(sample_map PUBLIC bank_book wav_codec sampler_core)
|
||||
target_link_libraries(sample_map PUBLIC bank_book wav_codec velocity_curve peaks)
|
||||
|
||||
# component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState
|
||||
# ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every
|
||||
# lift preserved byte-identically). Split so the codec — which grows on every envelope
|
||||
# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links
|
||||
# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and
|
||||
# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters
|
||||
# the extension binary. Its own test target linking exactly these is the structural proof.
|
||||
# ENVELOPE + params-payload binary codec (envelope v1..v11, params payload v1..v8, every
|
||||
# lift preserved byte-identically; v1..v7 are the retired zone lists, read via the
|
||||
# adopt-zone-one migration). Split so the codec — which grows on every envelope bump and is
|
||||
# shared with the EXTENSION's preset-blob path (instrument_drop) — links WITHOUT the voice
|
||||
# engine: its deps are velocity_curve (the curve field) and master_gain (the v8 wire cap)
|
||||
# only; sampler_core/pitch_shift object code never enters the extension binary. Its own test
|
||||
# target linking exactly these is the structural proof.
|
||||
add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp)
|
||||
target_include_directories(component_state_io PUBLIC src)
|
||||
target_link_libraries(component_state_io PUBLIC velocity_curve master_gain)
|
||||
@@ -872,10 +897,9 @@ add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp)
|
||||
target_include_directories(capture_browser PUBLIC src)
|
||||
target_link_libraries(capture_browser PUBLIC editor_geometry)
|
||||
|
||||
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects +
|
||||
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's
|
||||
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of
|
||||
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
|
||||
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, and the
|
||||
# drag-delta note resolver for the editor's keyboard strip (root display + root-set). The
|
||||
# mirror of embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
|
||||
add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp)
|
||||
target_include_directories(keyboard_strip PUBLIC src)
|
||||
target_link_libraries(keyboard_strip PUBLIC editor_geometry)
|
||||
@@ -908,13 +932,7 @@ target_link_libraries(bank_sync PRIVATE wire)
|
||||
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
|
||||
add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp)
|
||||
target_include_directories(browser_scroll PUBLIC src)
|
||||
target_link_libraries(browser_scroll PUBLIC capture_browser)
|
||||
|
||||
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
|
||||
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
|
||||
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
|
||||
add_library(note_entry STATIC src/core/instrument/map/note_entry.cpp)
|
||||
target_include_directories(note_entry PUBLIC src)
|
||||
target_link_libraries(browser_scroll PUBLIC capture_browser sample_chrome)
|
||||
|
||||
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
|
||||
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
|
||||
@@ -937,8 +955,8 @@ target_include_directories(trigger_seam PUBLIC src)
|
||||
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
|
||||
# duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps.
|
||||
# The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect.
|
||||
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored
|
||||
# AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
|
||||
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the one parameter
|
||||
# set's stored AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
|
||||
add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp)
|
||||
target_include_directories(envelope_overlay PUBLIC src)
|
||||
target_link_libraries(envelope_overlay PUBLIC editor_geometry)
|
||||
@@ -974,9 +992,16 @@ target_link_libraries(curve_popup PUBLIC editor_geometry)
|
||||
add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
|
||||
target_include_directories(master_gain PUBLIC src)
|
||||
|
||||
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
|
||||
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
|
||||
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
|
||||
# sample_bands: the band-stack allocator's vertical inventory, asserted as pure geometry
|
||||
# (chrome / two-lane waveform / deck row) independent of any paint call — the contract the
|
||||
# band owners downstream read.
|
||||
add_executable(sample_bands_tests tests/test_sample_bands.cpp)
|
||||
target_link_libraries(sample_bands_tests PRIVATE sample_bands)
|
||||
add_test(NAME sample_bands_tests COMMAND sample_bands_tests)
|
||||
|
||||
add_executable(sample_chrome_tests tests/test_sample_chrome.cpp)
|
||||
target_link_libraries(sample_chrome_tests PRIVATE sample_chrome)
|
||||
add_test(NAME sample_chrome_tests COMMAND sample_chrome_tests)
|
||||
|
||||
add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp)
|
||||
target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal)
|
||||
@@ -1026,11 +1051,6 @@ add_executable(browser_scroll_tests tests/test_browser_scroll.cpp)
|
||||
target_link_libraries(browser_scroll_tests PRIVATE browser_scroll)
|
||||
add_test(NAME browser_scroll_tests COMMAND browser_scroll_tests)
|
||||
|
||||
# note_entry (S12): the pure text->clamped-MIDI-note parse for direct numeric entry.
|
||||
add_executable(note_entry_tests tests/test_note_entry.cpp)
|
||||
target_link_libraries(note_entry_tests PRIVATE note_entry)
|
||||
add_test(NAME note_entry_tests COMMAND note_entry_tests)
|
||||
|
||||
# param_slider (S12 + S15/S16 control surfaces): the pure control-panel layout + slider/toggle
|
||||
# value<->pixel mapping the editor parameter surface draws + routes against.
|
||||
add_executable(param_slider_tests tests/test_param_slider.cpp)
|
||||
@@ -1253,16 +1273,26 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
src/shell/instrument/reasampler_processor.cpp
|
||||
src/shell/instrument/processor_state.cpp
|
||||
src/shell/instrument/processor_reload.cpp
|
||||
# The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state,
|
||||
# param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform;
|
||||
# the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry
|
||||
# + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU).
|
||||
# The editor family, split on the Sample face's BAND axis: session/bridge state,
|
||||
# param plumbing + the shared band-layout resolve, then paint and input in matching
|
||||
# sets — dispatch, chrome, waveform, decks — plus the two band-independent surfaces
|
||||
# (the Browse modal and the velocity-curve popup) and the platform/window TU. The
|
||||
# layout math itself is PURE (core/instrument/ui/sample_bands + sample_chrome +
|
||||
# browser_scroll). Shared internals: editor_internal.h (no TU).
|
||||
src/shell/instrument/editor_session.cpp
|
||||
src/shell/instrument/editor_controls.cpp
|
||||
src/shell/instrument/editor_paint_sample.cpp
|
||||
src/shell/instrument/editor_paint_browse_zone.cpp
|
||||
src/shell/instrument/editor_input_sample.cpp
|
||||
src/shell/instrument/editor_input_browse_zone.cpp
|
||||
src/shell/instrument/editor_paint.cpp
|
||||
src/shell/instrument/editor_paint_chrome.cpp
|
||||
src/shell/instrument/editor_paint_waveform.cpp
|
||||
src/shell/instrument/editor_paint_deck.cpp
|
||||
src/shell/instrument/editor_paint_browse.cpp
|
||||
src/shell/instrument/editor_paint_curve.cpp
|
||||
src/shell/instrument/editor_input.cpp
|
||||
src/shell/instrument/editor_input_chrome.cpp
|
||||
src/shell/instrument/editor_input_waveform.cpp
|
||||
src/shell/instrument/editor_input_deck.cpp
|
||||
src/shell/instrument/editor_input_browse.cpp
|
||||
src/shell/instrument/editor_input_curve.cpp
|
||||
src/shell/instrument/editor_platform.cpp
|
||||
src/shell/instrument/reasampler_embed.cpp
|
||||
src/shell/instrument/reaper_bridge.cpp
|
||||
@@ -1278,16 +1308,21 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
${LICE_SRC}
|
||||
)
|
||||
# editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure
|
||||
# bank->keymap mapping + state (de)ser the processor drives off the audio thread;
|
||||
# linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model,
|
||||
# peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile /
|
||||
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src)
|
||||
# gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...).
|
||||
# bank -> one-capture resolve + SampleData build the processor drives off the audio
|
||||
# thread; linking it pulls its pure deps (bank_book, wav_codec, velocity_curve, peaks)
|
||||
# transitively — deliberately NOT sampler_core (the voice engine). capture_paths: the
|
||||
# shared M4 path resolution (resolveBankFile / projectDirOfRpp) the bridge + processor
|
||||
# use. Its PUBLIC include dir (src)
|
||||
# gives the shell TUs their headers (ext_keys.h, bank_book.h, voice_engine.h, ...).
|
||||
# embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals
|
||||
# into; it links editor_geometry transitively (shared Rect).
|
||||
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so
|
||||
# the instrument reads the SAME namespace the extension writes; its PUBLIC include dir
|
||||
# (build/generated) carries version_generated.h for the channel bit.
|
||||
# sampler_core: the voice engine the processor drives (sample_map no longer pulls it —
|
||||
# its build yields plain SampleData — so the module links it directly.)
|
||||
# sample_bands + sample_chrome: the band-stack allocator the Sample face's three band
|
||||
# TUs read, and the chrome band's interior geometry.
|
||||
# capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip
|
||||
# geometry the capture-first editor draws + hit-tests against; both link editor_geometry
|
||||
# transitively (shared Rect).
|
||||
@@ -1297,11 +1332,11 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
# bank_sync (S9/S8 reader): the pure generation-compare + assignment-consume decision the
|
||||
# processor's off-thread poll runs; links assignment_request transitively (the decoded
|
||||
# request it consumes) — the same key the extension writes, shared via the pure module.
|
||||
# browser_scroll + note_entry + param_slider (S12 + S15/S16 control surfaces): the pure
|
||||
# scroll/search geometry over the capture browser, the numeric-note-entry parse, and the
|
||||
# control-panel layout + slider/toggle value<->pixel mapping the editor's parameter surface
|
||||
# draws + routes against. browser_scroll pulls capture_browser transitively; param_slider +
|
||||
# note_entry link editor_geometry / the stdlib only. All engine-free, DAW-verified in the shell.
|
||||
# browser_scroll + param_slider (S12 + S15/S16 control surfaces): the pure scroll/search
|
||||
# geometry over the capture browser, and the control-panel layout + slider/toggle
|
||||
# value<->pixel mapping the editor's parameter surface draws + routes against.
|
||||
# browser_scroll pulls capture_browser transitively; param_slider links editor_geometry /
|
||||
# the stdlib only. All engine-free, DAW-verified in the shell.
|
||||
# theme + component_geometry + bank_grid: the Phase L (L1) draw-kit's PURE deps (L3). The
|
||||
# kit draws every editor/embed surface by palette ROLE via draw_kit.cpp (compiled into the
|
||||
# module above): theme supplies role->KitColor + spectralColor, component_geometry the
|
||||
@@ -1317,8 +1352,9 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
|
||||
# sample_usage (pS-usage): the usage-record wire + publish plan the processor's
|
||||
# reloadInstrument publishes through the bridge (the one sanctioned VST-side write).
|
||||
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
|
||||
sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip
|
||||
waveform_view bank_sync browser_scroll note_entry param_slider
|
||||
sampler_core sample_map component_state_io capture_paths embed_strip app_version
|
||||
capture_browser keyboard_strip sample_bands sample_chrome
|
||||
waveform_view bank_sync browser_scroll param_slider
|
||||
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
|
||||
knob_deck curve_popup master_gain sample_usage file_bytes)
|
||||
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
|
||||
subdirectories:
|
||||
|
||||
- **`engine/`** — the polyphonic voice engine, per-zone play params, pitch shifting,
|
||||
- **`engine/`** — the polyphonic voice engine, the one set of play params, pitch shifting,
|
||||
velocity curve, and master-gain taper math.
|
||||
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the
|
||||
small pure helpers the engine/shell share (bank-generation sync, bridge-read
|
||||
marshalling, note-name parsing, Trigger frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
|
||||
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
|
||||
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
|
||||
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
|
||||
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
|
||||
- **`map/`** — the capture resolution + `SampleData` build, the cross-artifact
|
||||
`ComponentState` codec, and the small pure helpers the engine/shell share
|
||||
(bank-generation sync, bridge-read marshalling, note-name parsing, Trigger
|
||||
frame↔fraction conversion).
|
||||
- **`ui/`** — pure editor geometry/hit-test modules (the band-stack allocator and its band
|
||||
interiors, waveform, keyboard strip, capture browser, param controls, envelope
|
||||
overlay/edit). These are geometry-and-math only; the LICE draw + REAPER/VST3 plumbing is
|
||||
the `shell/instrument` editor shell, along with the VST3 processor, `reaper_bridge`,
|
||||
`reasampler_embed`, and `vst_entry`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -39,6 +40,21 @@ subdirectories:
|
||||
audio — the bank index, the mapping, which project is active — the instrument reads
|
||||
the live `"reasampler"` ext-state via the bridge.
|
||||
|
||||
### One capture = one parameter set
|
||||
|
||||
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
|
||||
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
|
||||
captures: every playback parameter edits in exactly one place, and no gesture can express
|
||||
per-zone divergence. The root note survives as a first-class parameter of that one set.
|
||||
|
||||
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
|
||||
its root, with key-tracking applied. A user-settable low/high playable range is
|
||||
re-addable later as two ordinary parameters if it is ever missed.
|
||||
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
|
||||
one's capture and zone one's parameters; the rest drop, touching no file and no bank
|
||||
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
|
||||
relaxed for a genuinely multi-zone instance.
|
||||
|
||||
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
|
||||
|
||||
The split model is the settled answer, mirroring the capture/placement separation:
|
||||
@@ -47,20 +63,18 @@ The split model is the settled answer, mirroring the capture/placement separatio
|
||||
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
|
||||
notes) are facts about the file, added as an additive field extension (same shape as
|
||||
`provenance`).
|
||||
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
|
||||
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
|
||||
trim are a performance choice, not a fact about a file — they belong to the instrument,
|
||||
not the bank. This "who owns which field" rule (D-B) governs every performance-map
|
||||
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
|
||||
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
|
||||
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
|
||||
the bank.
|
||||
- **Performance choices live in the instrument.** Amplitude envelopes and per-sample
|
||||
tuning/gain trim are a performance choice, not a fact about a file — they belong to the
|
||||
instrument, not the bank. This "who owns which field" rule (D-B) governs every parameter
|
||||
added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode and pitch
|
||||
envelope (S16), key-tracking, preview velocity, and the velocity curve (S-VIEW) — all are
|
||||
per-instance `ComponentState`, never written to `Sample` or the bank.
|
||||
|
||||
### The pure core (D3 — the load-bearing split)
|
||||
|
||||
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
|
||||
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
|
||||
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation are
|
||||
a pure, REAPER-free, DAW-free, unit-tested module — the mirror of
|
||||
`bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
|
||||
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
|
||||
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
|
||||
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
|
||||
@@ -112,7 +126,7 @@ pitch envelope/curve (AD?) which is off by default."*
|
||||
held/out of scope (fork S15-F1).
|
||||
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
|
||||
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
|
||||
- **Pitch engine — Varispeed vs Preserve (per-zone toggle, S16).** Varispeed (current/
|
||||
- **Pitch engine — Varispeed vs Preserve (S16).** Varispeed (current/
|
||||
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
|
||||
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
|
||||
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
|
||||
@@ -157,25 +171,25 @@ The amp envelope is drawn as a curve over the Sample view's hero waveform at the
|
||||
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
|
||||
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
|
||||
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
|
||||
one model: both read/write the same zone envelope fields, so a drag updates the params,
|
||||
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth,
|
||||
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a
|
||||
node cannot be dragged past its neighbours) and range-clamped to the same per-param
|
||||
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't.
|
||||
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay`
|
||||
and `envelope_edit` in Modules below.
|
||||
one model: both read/write the same envelope fields of the one parameter set, so a drag
|
||||
updates the params, the sliders reflect them live, and a slider edit re-lays the nodes —
|
||||
one source of truth, structural (re-read-every-paint), not a listener chain. Nodes are
|
||||
monotonic in time (a node cannot be dragged past its neighbours) and range-clamped to the
|
||||
same per-param min/max the sliders enforce, so node-drag can never produce a param the
|
||||
slider couldn't. Two pure modules split the forward (draw) and inverse (edit) maps — see
|
||||
`envelope_overlay` and `envelope_edit` in Modules below.
|
||||
|
||||
### New performance-map parameters — ownership and persistence (D-B)
|
||||
### Parameter ownership and persistence (D-B)
|
||||
|
||||
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
|
||||
- **Key-tracking** — additive/version-bumped component state, default 100%
|
||||
(absent field on an older blob lifts to 100%, bit-identical playback).
|
||||
- **Preview velocity** — a per-instance utility setting for the Sample view's
|
||||
preview-trigger button (not a musical parameter of the capture); **persists across
|
||||
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
|
||||
extension's `persist` ext-state module (that would make it project-global rather than
|
||||
per-instance and leak an instrument concern into the extension's key space).
|
||||
- **Velocity curve** — per-zone; the one non-back-compat surface in S-VIEW: an
|
||||
already-saved zone with no stored curve now plays every velocity at unity under the
|
||||
- **Velocity curve** — the one non-back-compat surface in S-VIEW: an
|
||||
already-saved instance with no stored curve now plays every velocity at unity under the
|
||||
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
|
||||
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
|
||||
|
||||
@@ -183,25 +197,29 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
### `engine/`
|
||||
|
||||
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `zone_params.h` (`core/instrument/engine`) is the sibling header split out of `sampler_core.h` (T4-14/T4-17): the per-zone play-parameter value structs (`ZonePlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`) and the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`) the engine, the codec, and the editor all share.
|
||||
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes.
|
||||
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample.
|
||||
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU.
|
||||
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `pitch_shift` — hand-rolled **correlation-aligned SOLA** (splice-overlap-add) pitch shifter for the Preserve playback mode: one active read tap chases the write head at the shift ratio; each splice jump is refined by a cross-correlation search so the new read point is waveform-aligned, then old and new taps are crossfaded (raised-cosine, amplitude-complementary). Replaces the prior dual-tap OLA whose fixed half-window tap offset caused anti-phase cancellation on many source frequencies. **GA2:** ring buffer **primed with the actual upcoming source** at note-on (was zero-filled) → gap-free frame-0 onset, ~25 ms Preserve onset latency eliminated (Preserve now speaks on frame 0, matching Varispeed), and real-content-bounded tail (last-window tail-truncation gone). No third-party dependencies; RT-discipline: no allocation in `process()`.
|
||||
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a Fritsch–Carlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
|
||||
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
|
||||
|
||||
### `map/`
|
||||
|
||||
- `sample_map` — zone payload: zones keyed by note range. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). JSON round-trip.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + zones-payload binary codec (envelope v1…v11, zones-payload v1…v7), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine (`sampler_core`/`pitch_shift`) to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift.
|
||||
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v8), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id).
|
||||
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
|
||||
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
|
||||
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
|
||||
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
|
||||
|
||||
### `ui/`
|
||||
|
||||
- `editor_geometry` (`core/instrument/ui`) — VST3 editor layout: aliases the shared `core::ui::Rect` (+ `contains()`) rather than defining its own; owns `EditorLayout`/`layoutEditor(w,h)`, the Tier-0/Tier-1 sample-list and keymap-editor row layout/hit-test, and — hoisted here off the former `reasampler_editor.cpp` god-TU (Q-W2v, T2-06) — the r11 Sample-face band layout (`SampleBands`/`ClusterRects`/`channelToggleRects`) and the Zone-face content/legend/deck layout, so the editor shell only draws + routes.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, zone highlight overlay geometry.
|
||||
- `editor_geometry` (`core/instrument/ui`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias + `contains()`, nothing else. Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory: three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split. A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + Browse) over the control row (root strip, preview, velocity knob cell, curve button, channel toggle). The fixed run is right-anchored; the root strip takes the remainder.
|
||||
- `keyboard_strip` — piano-keyboard strip: MIDI-note→key rect mapping, black/white key layout, hit-test, root-marker rect, and the drag-delta note resolver.
|
||||
- `waveform_view` — waveform/marker geometry: maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||
- `capture_browser` — capture browser: card-grid layout + bank-filter tab strip geometry and hit-test; knows only counts and rects, draws nothing.
|
||||
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
|
||||
@@ -216,7 +234,9 @@ and `envelope_edit` in Modules below.
|
||||
|
||||
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
|
||||
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
|
||||
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
|
||||
- **`param_slider`'s linear slider rows are retired on the parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||
- **Two superseded designs are called out in Invariants above**: the earlier
|
||||
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
|
||||
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
#pragma once
|
||||
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||
// the partial attack/decay level, not from sustainLevel.
|
||||
void noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||
// output frame.
|
||||
double tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at
|
||||
// 1.0 beyond what Attack already emitted) so a zero-length hold emits no
|
||||
// extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||
// recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||
// the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on
|
||||
// readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
+33
-20
@@ -1,18 +1,20 @@
|
||||
#pragma once
|
||||
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
|
||||
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
|
||||
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
|
||||
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
|
||||
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
||||
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
||||
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
||||
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
||||
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
@@ -25,8 +27,7 @@ enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
|
||||
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
|
||||
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
@@ -45,8 +46,7 @@ struct AdsrParams {
|
||||
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Per-zone; default Gate so an instrument with no params set plays
|
||||
// exactly as before.
|
||||
// honor the start point. Default Gate so an instrument with no params set plays as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
@@ -69,10 +69,10 @@ inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
// (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
|
||||
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
|
||||
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
|
||||
// tests (an octave up still halves duration with no params set).
|
||||
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
||||
// the editor's default params), NOT here: PlayParams.pitchEngine itself defaults to Varispeed
|
||||
// so "no params == the bare engine" holds for the core's own regression tests (an octave up
|
||||
// still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
@@ -93,7 +93,7 @@ struct PitchEnvParams {
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
struct PlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
@@ -101,9 +101,6 @@ struct ZonePlayParams {
|
||||
PitchEnvParams pitchEnv;
|
||||
};
|
||||
|
||||
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||
struct SampleLoop {
|
||||
@@ -112,11 +109,14 @@ struct SampleLoop {
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
//
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
|
||||
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
|
||||
// channels share the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
||||
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
@@ -130,13 +130,26 @@ struct SampleData {
|
||||
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
ZonePlayParams play;
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 = no
|
||||
// tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root) semitone
|
||||
// offset in keyTrackedRatio; rides both repitch engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
PlayParams play;
|
||||
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
||||
// voice on an empty read span.
|
||||
bool playable() const { return !frames.empty(); }
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,956 +0,0 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
|
||||
//
|
||||
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
|
||||
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
|
||||
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
|
||||
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
|
||||
// this file further; the header is split instead (zone_params.h carries the value structs).
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pitchRatio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double pitchRatio(int note, int rootNote) {
|
||||
// Equal temperament: each semitone is a factor of 2^(1/12). note == root -> 1.0.
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
|
||||
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ZoneResolution Keymap::resolve(int note, int velocity) const {
|
||||
(void)velocity; // accepted for the Tier-2 seam; does not select at Tier 0-1.
|
||||
for (std::size_t i = 0; i < zones.size(); ++i) {
|
||||
const KeyZone& z = zones[i];
|
||||
if (note >= z.lowNote && note <= z.highNote) {
|
||||
return ZoneResolution{true, i};
|
||||
}
|
||||
}
|
||||
return ZoneResolution{false, 0};
|
||||
}
|
||||
|
||||
Keymap Keymap::singleSampleChromatic(SampleData sample) {
|
||||
const int root = sample.rootNote;
|
||||
Keymap km;
|
||||
km.samples.push_back(std::move(sample));
|
||||
KeyZone zone;
|
||||
zone.lowNote = 0;
|
||||
zone.highNote = 127;
|
||||
zone.rootNote = root;
|
||||
zone.sampleIndex = 0;
|
||||
km.zones.push_back(zone);
|
||||
return km;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdsrEnvelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AdsrEnvelope::noteOn() {
|
||||
stage_ = Stage::Attack;
|
||||
level_ = 0.0;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
void AdsrEnvelope::noteOff() {
|
||||
if (stage_ == Stage::Idle || stage_ == Stage::Finished ||
|
||||
stage_ == Stage::Release) {
|
||||
return; // already released / not sounding.
|
||||
}
|
||||
// Release from the CURRENT level — release-before-sustain releases from the
|
||||
// partial attack/decay level, not from sustainLevel.
|
||||
releaseFrom_ = level_;
|
||||
stage_ = Stage::Release;
|
||||
framesInStage_ = 0;
|
||||
}
|
||||
|
||||
double AdsrEnvelope::tick() {
|
||||
switch (stage_) {
|
||||
case Stage::Idle:
|
||||
case Stage::Finished:
|
||||
level_ = 0.0;
|
||||
return 0.0;
|
||||
|
||||
case Stage::Attack: {
|
||||
if (params_.attackFrames <= 0) {
|
||||
level_ = 1.0;
|
||||
} else {
|
||||
level_ = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.attackFrames);
|
||||
if (level_ > 1.0) level_ = 1.0;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
|
||||
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.holdFrames) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Decay: {
|
||||
if (params_.decayFrames <= 0) {
|
||||
level_ = params_.sustainLevel;
|
||||
} else {
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.decayFrames);
|
||||
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||
}
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.decayFrames) {
|
||||
stage_ = Stage::Sustain;
|
||||
framesInStage_ = 0;
|
||||
level_ = params_.sustainLevel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case Stage::Sustain:
|
||||
level_ = params_.sustainLevel;
|
||||
return level_;
|
||||
|
||||
case Stage::Release: {
|
||||
if (params_.releaseFrames <= 0) {
|
||||
level_ = 0.0;
|
||||
stage_ = Stage::Finished;
|
||||
return 0.0;
|
||||
}
|
||||
const double t = static_cast<double>(framesInStage_) /
|
||||
static_cast<double>(params_.releaseFrames);
|
||||
level_ = releaseFrom_ * (1.0 - t);
|
||||
if (level_ < 0.0) level_ = 0.0;
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.releaseFrames) {
|
||||
stage_ = Stage::Finished;
|
||||
level_ = 0.0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return 0.0; // unreachable; silences a warning.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve) {
|
||||
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||
curve_ = curve;
|
||||
finished_ = (playLength_ <= 0);
|
||||
|
||||
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end). A
|
||||
// negative fade is treated as 0. When both fades together exceed the play length, shrink
|
||||
// the fade-out first (the head fade-in is the more perceptually load-bearing onset ramp),
|
||||
// then the fade-in — never letting either go negative or the sum exceed the span.
|
||||
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||
if (fi > playLength_) fi = playLength_;
|
||||
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||
fadeIn_ = fi;
|
||||
fadeOut_ = fo;
|
||||
}
|
||||
|
||||
double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
if (finished_ || sourceOffset < 0.0 ||
|
||||
sourceOffset >= static_cast<double>(playLength_)) {
|
||||
// At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd.
|
||||
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over [playLength_-fadeOut_, playLength_).
|
||||
// Unity between. The two ramps never overlap (configure clamps fadeIn_ + fadeOut_ <= length).
|
||||
// The offset is fractional (the read head is fractional under repitch), so the ramps are
|
||||
// smooth rather than stepped.
|
||||
double amp = 1.0;
|
||||
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): 0->1 constant power
|
||||
: phase;
|
||||
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_); // 0..1
|
||||
amp = (curve_ == FadeCurve::EqualPower)
|
||||
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): 1->0 constant power
|
||||
: (1.0 - phase);
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
if (!params_.enabled) return 0.0;
|
||||
|
||||
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||
const double peak = params_.peakSemitones;
|
||||
|
||||
double offset;
|
||||
if (pos_ < a) {
|
||||
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||
} else if (pos_ < a + d) {
|
||||
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||
offset = peak * (1.0 - t);
|
||||
} else {
|
||||
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||
}
|
||||
++pos_;
|
||||
return offset;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
bool Voice::sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const ZonePlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
|
||||
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
|
||||
// fade-in/out over the % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop geometry,
|
||||
// not on channel PCM values) — compute `p` once for channel 0, reuse for channel 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a legato
|
||||
// phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
|
||||
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under either
|
||||
// engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
|
||||
// finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant. (An
|
||||
// earlier revision stored the frozen difference (ref − x₀), which could exceed full scale
|
||||
// if outₙ rose while that residue was still large.)
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
|
||||
// once and applied identically to every channel — only the PCM value read differs. The
|
||||
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
|
||||
// non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
|
||||
// "no loop". Under Preserve the loop is over the source read (loop the source, shift the
|
||||
// output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new play
|
||||
// span ends within the ramp). With no declick (the common case) this is byte-identical
|
||||
// to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref − 0) == w*ref.
|
||||
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight for both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
|
||||
// entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose the
|
||||
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
|
||||
// amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
|
||||
// were primed with that window at start()), under the same sustain-loop wrap rule,
|
||||
// reading integer source frames (nothing to interpolate). Past the last real frame
|
||||
// the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input below is ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
|
||||
// 0's splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum combing.
|
||||
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
|
||||
// that would advance its heads twice and corrupt the state). Gated on haveR so
|
||||
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
|
||||
// stereo samples, and a stale un-primed ring must not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
|
||||
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
|
||||
// this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the pitch
|
||||
// envelope multiplies the ratio for the read-rate bias (unchanged pre-S16 idiom when the
|
||||
// envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head. For
|
||||
// the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2, bounded-blend revision): on the FIRST frame
|
||||
// after a takeover/steal restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [Rev 1 added the frozen difference (ref − x₀) ungated; if outₙ rose while the residue
|
||||
// was still large the sum could exceed ±1 by up to ~+3.8 dB on an extreme retrig.]
|
||||
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
|
||||
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any running
|
||||
// declick) — a future takeover restart seeds its declick from exactly this. In a mono
|
||||
// render the R track mirrors L (dual-mono semantics, matching the stereo mirror of a mono
|
||||
// sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still rings:
|
||||
// the envelope contributes 0 from here on, so the remaining frames are the bare ramp
|
||||
// fading out (bounded: the ramp floors within ~4 ms). Baseline (no declick) unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
AudioSample Voice::renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
void Voice::renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
keymap_(keymap),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
// MONO: voices_.size() == 1, so the loop below sizes exactly one voice regardless of
|
||||
// maxVoices — the Poly path sizes the whole pool as before.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped (kNoVoice return at :797-800) during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play, never joins the stack.
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) return kNoVoice;
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER zones:
|
||||
// Voice::release() is a no-op in Trigger, so releasing_ never latches, and a one-shot
|
||||
// still ringing after the last key-up was silently RETUNED in place instead of
|
||||
// re-attacked. NOTE: a one-held-note same-note re-press (heldCount_ becomes 1 after the
|
||||
// removeHeld/re-push above — so heldCount_ < 2) re-attacks rather than retuning, which is
|
||||
// the correct fresh-phrase behavior for that edge case.) Same-sample requirement unchanged.
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (monoNoteOn falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato &&
|
||||
v.playingSample() == &sample) {
|
||||
v.retune(note, zone.rootNote, zone.keyTrack);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase / cross-sample legato: (re)start the voice.
|
||||
// The declick opt-in rides every mono restart: start() self-gates it on the voice being
|
||||
// ACTIVE, so a first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger zones ignore this and play through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
const ZoneResolution res = keymap_.resolve(fb.note, fb.velocity);
|
||||
if (!res.matched || keymap_.zones[res.zoneIndex].sampleIndex >= keymap_.samples.size()) {
|
||||
v.release(); // defensive: only resolving notes are pushed, so this shouldn't happen.
|
||||
return;
|
||||
}
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
if (monoTrigger_ == MonoTrigger::Legato && v.playingSample() == &sample) {
|
||||
v.retune(fb.note, zone.rootNote, zone.keyTrack); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger (or cross-sample) fallback: re-strike the fallen-back-to note at its own
|
||||
// original velocity. Peer restart site of monoNoteOn's takeover — same declick opt-in
|
||||
// (the fallback also hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
const ZoneResolution res = keymap_.resolve(note, velocity);
|
||||
if (!res.matched) return kNoVoice; // out-of-zone: defined no-play.
|
||||
|
||||
const KeyZone& zone = keymap_.zones[res.zoneIndex];
|
||||
if (zone.sampleIndex >= keymap_.samples.size()) {
|
||||
return kNoVoice; // zone points at a missing sample — refuse rather than UB.
|
||||
}
|
||||
const SampleData& sample = keymap_.samples[zone.sampleIndex];
|
||||
|
||||
// S16 Preserve voice cap: a Preserve note is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play, mirroring out-of-zone — no shifter is allocated).
|
||||
// Varispeed notes are unaffected. A voice already sounding is never cut by this cap; only
|
||||
// NEW Preserve onsets past the cap are refused (the spec's "cap kicks in rather than glitch").
|
||||
if (preserveVoiceCap_ > 0 && sample.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too (GA fix): start() self-gates on the
|
||||
// voice being active, so a free-voice start never ramps — only an at-cap steal, which is
|
||||
// the same hard cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample, zone.rootNote, zone.keyTrack, zone.velocityCurve,
|
||||
/*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,485 +0,0 @@
|
||||
#pragma once
|
||||
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
|
||||
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
|
||||
// repitch/interpolation from a root note with loop-point-aware sustain.
|
||||
//
|
||||
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
|
||||
// enter as plain int/frame-index inputs; the core does no file I/O.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/zone_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
|
||||
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
|
||||
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
|
||||
// zones, each a key range with its own root.
|
||||
//
|
||||
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
|
||||
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
|
||||
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
|
||||
// receives but currently ignores for selection — no signature change needed.
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
|
||||
// note to repitch from (defaults to the sample's own root, overridable per zone).
|
||||
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
|
||||
// velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
|
||||
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
|
||||
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// `matched == false` means the note falls in no zone — a defined no-play result, not an
|
||||
// error and not voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
|
||||
// in order, so an earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
|
||||
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
|
||||
// keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
|
||||
// reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
|
||||
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
|
||||
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
|
||||
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||
|
||||
void configure(const AdsrParams& params) { params_ = params; }
|
||||
|
||||
// Gate on: (re)start from Attack.
|
||||
void noteOn();
|
||||
// Gate off: enter Release from the current level.
|
||||
void noteOff();
|
||||
|
||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
||||
// one value per output frame.
|
||||
double tick();
|
||||
|
||||
Stage stage() const { return stage_; }
|
||||
bool finished() const { return stage_ == Stage::Finished; }
|
||||
double level() const { return level_; }
|
||||
|
||||
private:
|
||||
AdsrParams params_;
|
||||
Stage stage_ = Stage::Idle;
|
||||
double level_ = 0.0;
|
||||
std::int64_t framesInStage_ = 0;
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
|
||||
private:
|
||||
std::int64_t playLength_ = 0;
|
||||
std::int64_t fadeIn_ = 0;
|
||||
std::int64_t fadeOut_ = 0;
|
||||
FadeCurve curve_ = kDefaultFadeCurve;
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
double tick();
|
||||
|
||||
private:
|
||||
PitchEnvParams params_;
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
|
||||
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
|
||||
// frame — a step discontinuity that clicks. When the caller opts in (start()'s
|
||||
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
|
||||
// the first frame after the restart seeds a compensation equal to
|
||||
// (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
|
||||
// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
||||
// the sample by fractional frame position with linear interpolation, advancing by
|
||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
|
||||
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
|
||||
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
|
||||
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
|
||||
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
|
||||
// inside process(); the warm silence pass settles the OLA taps before the first output
|
||||
// frame. Byte-identical to the bare engine when sample.play is default.
|
||||
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
|
||||
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
|
||||
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
|
||||
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
|
||||
// difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
|
||||
// guarantees the voice is playing the same SampleData the resolved zone names — a
|
||||
// cross-sample takeover must restart the voice instead.
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// Identity only, never mutated through; the engine's mono legato path compares it
|
||||
// against the new note's resolved sample to decide retune vs. restart.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame();
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const;
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and
|
||||
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
|
||||
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
|
||||
// (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed
|
||||
// full scale by several dB.)
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
|
||||
// voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
|
||||
// engine — held by reference, never copies PCM). Play params ride on each zone's
|
||||
// SampleData::play; the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
|
||||
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
|
||||
// leaves them pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
|
||||
// same-sample takeover without a re-attack). The engine's config is immutable — a
|
||||
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
|
||||
// reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
|
||||
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
|
||||
// self-gates on the voice being active, so a fresh start never ramps. Default false
|
||||
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, zone-resolving note in press order (top = most recent = the sounding
|
||||
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
|
||||
// the voice back on a fallback). Re-pressing a held note moves it to the top.
|
||||
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
|
||||
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
|
||||
// velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
|
||||
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
|
||||
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
|
||||
// not applied in mono — a single voice runs at most one shifter, inherently within any
|
||||
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,174 @@
|
||||
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
}
|
||||
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||
declickPending_ = true;
|
||||
} else {
|
||||
declickPending_ = false;
|
||||
}
|
||||
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||
declickActive_ = false;
|
||||
declickWeight_ = 0.0;
|
||||
|
||||
active_ = true;
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
const PlayParams& p = sample.play;
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
|
||||
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||
// % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
playEnd_ = 0; // unused in Gate
|
||||
} else {
|
||||
// Trigger: play [start, playEnd) where
|
||||
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||
double frac = p.trigger.lengthFraction;
|
||||
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||
if (frac > 1.0) frac = 1.0;
|
||||
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||
std::int64_t playLen = static_cast<std::int64_t>(
|
||||
static_cast<double>(span) * frac + 0.5); // round
|
||||
if (playLen < 0) playLen = 0;
|
||||
if (playLen > span) playLen = span;
|
||||
playEnd_ = start + playLen;
|
||||
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const std::int64_t primeCount =
|
||||
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||
std::int64_t p = start;
|
||||
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||
std::int64_t q = start;
|
||||
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||
if (loopWrap) {
|
||||
while (q >= loop.end) q -= loopLen;
|
||||
}
|
||||
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||
primeBuf_[static_cast<std::size_t>(i)] =
|
||||
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||
++q;
|
||||
}
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
}
|
||||
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||
}
|
||||
|
||||
void Voice::retune(int note) {
|
||||
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||
if (!active_ || sample_ == nullptr) return;
|
||||
note_ = note;
|
||||
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||
}
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,441 @@
|
||||
#pragma once
|
||||
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
|
||||
//
|
||||
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
|
||||
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
|
||||
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/envelopes.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||
// reference-frequency needed.
|
||||
inline double pitchRatio(int note, int rootNote) {
|
||||
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||
}
|
||||
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before the
|
||||
// ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754 for an integer-valued double, feeding the same
|
||||
// std::pow call); 0.0 means every key plays the root pitch; 2.0 doubles the tracking rate.
|
||||
// At the root note the offset is 0 regardless of keyTrack.
|
||||
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback or a
|
||||
// poly at-cap steal) hard-cuts the old tone in one frame — a step discontinuity that clicks.
|
||||
// When the caller opts in (start()'s declickTakeover), start() records the last rendered
|
||||
// output as a pre-cut reference, and the first frame after the restart seeds a compensation
|
||||
// equal to (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless of
|
||||
// the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||
class Voice {
|
||||
public:
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
|
||||
// instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
|
||||
// AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
|
||||
// seconds at load). Preserve shifters must already be pre-sized
|
||||
// (presizePreserveShifters, off-thread) — start() only reset()s + warm()s them (RT-safe,
|
||||
// no allocation) since it runs on the audio thread inside process(). Byte-identical to
|
||||
// the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
|
||||
// amp gain, evaluated once here (off the per-frame path). `declickTakeover`: when true
|
||||
// and this voice is currently active (a takeover/steal restart, not a fresh start), arms
|
||||
// the difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, bool declickTakeover = false);
|
||||
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice.
|
||||
void retune(int note);
|
||||
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop() { active_ = false; }
|
||||
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame() {
|
||||
AudioSample discard = 0.0f;
|
||||
return advanceFrame(/*stereo=*/false, discard);
|
||||
}
|
||||
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r) {
|
||||
r = 0.0f;
|
||||
l = advanceFrame(/*stereo=*/true, r);
|
||||
}
|
||||
|
||||
private:
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const {
|
||||
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||
}
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Anchored to the source offset so fades land on the same source frames under
|
||||
// either engine's read rate. The voice also frees on readPos_ >= playEnd_ in
|
||||
// advanceFrame; finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
return amp;
|
||||
}
|
||||
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick() {
|
||||
// The weight starts at 1.0 so this frame's output is `out*(1-1) + ref*1 == ref` —
|
||||
// exact boundary identity whatever the new envelope's first value. Each subsequent
|
||||
// frame adds `w*(ref − outCurrent)` then decays w, so output is provably bounded by
|
||||
// max(|ref|, |outCurrent|) — mid-ramp overshoot is impossible even if outCurrent
|
||||
// rises while the weight is still significant. (An earlier revision stored the frozen
|
||||
// difference (ref − x₀), which could exceed full scale if outₙ rose while that
|
||||
// residue was still large.)
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
//
|
||||
// INLINE BY CONSTRAINT — see the file header.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR) {
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
|
||||
// valid, non-zero-length loop wraps the read head back into [start, end); a
|
||||
// zero-length loop is "no loop". Under Preserve the loop is over the source read
|
||||
// (loop the source, shift the output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new
|
||||
// play span ends within the ramp). With no declick (the common case) this is
|
||||
// byte-identical to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||
// floor-checked on the weight itself.
|
||||
const double l = declickWeight_ * declickRefL_;
|
||||
const double r = declickWeight_ * declickRefR_; // same weight both channels
|
||||
declickWeight_ *= kDeclickDecay;
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
active_ = false;
|
||||
}
|
||||
lastOutL_ = l;
|
||||
lastOutR_ = stereo ? r : l;
|
||||
if (stereo) outR = static_cast<AudioSample>(r);
|
||||
return static_cast<AudioSample>(l);
|
||||
}
|
||||
active_ = false;
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the
|
||||
// pow entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor =
|
||||
(pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose
|
||||
// the output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the
|
||||
// shift amount, not the read rate. The feed runs one window ahead of readPos_ (the
|
||||
// rings were primed with that window at start()), under the same sustain-loop wrap
|
||||
// rule, reading integer source frames (nothing to interpolate). Past the last real
|
||||
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
const bool exhausted = feedPos_ >= feedBound;
|
||||
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
const double shift = baseRatio_ * envFactor;
|
||||
shiftL_.setShiftRatio(shift);
|
||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||
outL = shiftedL * gain;
|
||||
if (stereo) {
|
||||
if (haveR && shiftR_.configured()) {
|
||||
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||
// splice decisions via processLinked — one correlation search, one lag, one
|
||||
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||
// (never twice — that would advance its heads twice and corrupt the state).
|
||||
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||
// not leak a previous note.
|
||||
if (exhausted) shiftR_.freezeTail();
|
||||
const AudioSample feedR =
|
||||
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||
shiftR_.setShiftRatio(shift);
|
||||
outRlocal =
|
||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
|
||||
gain;
|
||||
} else {
|
||||
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||
// shiftL_.process again this frame.
|
||||
outRlocal = shiftedL * gain;
|
||||
}
|
||||
}
|
||||
++feedPos_;
|
||||
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||
ratio_ = 1.0;
|
||||
} else {
|
||||
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||
// the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||
//
|
||||
// Linear interpolation between the two bracketing SOURCE frames at the read head.
|
||||
// For the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||
const double frac = readPos_ - static_cast<double>(i0);
|
||||
std::int64_t i1 = i0 + 1;
|
||||
if (loopUsable && i1 >= loop.end) {
|
||||
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||
}
|
||||
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||
outL = srcL * gain;
|
||||
if (stereo) {
|
||||
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||
outRlocal = srcR * gain;
|
||||
}
|
||||
ratio_ = baseRatio_ * envFactor;
|
||||
}
|
||||
|
||||
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||
// [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
|
||||
// while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
|
||||
// extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
|
||||
// one extra subtract.
|
||||
if (declickPending_) seedDeclick();
|
||||
if (declickActive_) {
|
||||
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||
outL += addL;
|
||||
if (stereo) outRlocal += addR;
|
||||
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||
declickActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||
|
||||
// Track the value this voice actually contributed THIS frame (post-gain, incl. any
|
||||
// running declick) — a future takeover restart seeds its declick from exactly this. In
|
||||
// a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
|
||||
// of a mono sample), so a later stereo takeover still has a sane R seed.
|
||||
lastOutL_ = outL;
|
||||
lastOutR_ = stereo ? outRlocal : outL;
|
||||
|
||||
readPos_ += ratio_;
|
||||
|
||||
// A finished amplitude envelope frees the voice — unless a takeover declick still
|
||||
// rings: the envelope contributes 0 from here on, so the remaining frames are the bare
|
||||
// ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
|
||||
if (amplitudeDone_ && !declickActive_) {
|
||||
active_ = false;
|
||||
}
|
||||
return static_cast<AudioSample>(outL);
|
||||
}
|
||||
|
||||
bool active_ = false;
|
||||
bool releasing_ = false;
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
PitchShifter shiftR_;
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
double declickRefR_ = 0.0;
|
||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||
double lastOutL_ = 0.0;
|
||||
double lastOutR_ = 0.0;
|
||||
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,272 @@
|
||||
// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the
|
||||
// block render loops. See voice_engine.h for the contract.
|
||||
//
|
||||
// The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in
|
||||
// voice.h precisely so this TU boundary costs nothing on the per-sample path.
|
||||
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap,
|
||||
std::int64_t preserveWindowFrames,
|
||||
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||
bool takeoverDeclick)
|
||||
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||
sample_(sample),
|
||||
preserveVoiceCap_(preserveVoiceCap),
|
||||
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||
takeoverDeclick_(takeoverDeclick) {
|
||||
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||
// allocation point for the shifter rings across the engine's lifetime.
|
||||
if (preserveWindowFrames > 1) {
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||
// be dropped during the narrow ~4 ms window the ramp lives.
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::allocateVoice() {
|
||||
// 1. A free (idle) voice, lowest index for determinism.
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (!voices_[i].active()) return i;
|
||||
}
|
||||
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||
std::size_t bestReleasing = kNoVoice;
|
||||
std::uint64_t bestReleasingOrder = 0;
|
||||
std::size_t bestOverall = kNoVoice;
|
||||
std::uint64_t bestOverallOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (voices_[i].releasing()) {
|
||||
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||
bestReleasing = i;
|
||||
bestReleasingOrder = order;
|
||||
}
|
||||
}
|
||||
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||
bestOverall = i;
|
||||
bestOverallOrder = order;
|
||||
}
|
||||
}
|
||||
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||
}
|
||||
|
||||
void VoiceEngine::removeHeld(int note) {
|
||||
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||
// Shift the notes above it down one slot (press order preserved).
|
||||
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||
--heldCount_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||
if (note < 0 || note > 127) return kNoVoice;
|
||||
// Nothing decoded: a defined no-play, and the note must not join the stack (it cannot
|
||||
// sound, so it must not later take the voice back on a fallback).
|
||||
if (!sample_.playable()) return kNoVoice;
|
||||
|
||||
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||
removeHeld(note);
|
||||
if (heldCount_ < heldStack_.size()) {
|
||||
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||
static_cast<std::uint8_t>(vclamped)};
|
||||
}
|
||||
|
||||
Voice& v = voices_[0];
|
||||
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||
// means another note was already physically held — the exact "takeover within a phrase"
|
||||
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is
|
||||
// a no-op there, so releasing_ never latches and a one-shot still ringing after the last
|
||||
// key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note
|
||||
// same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so
|
||||
// heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.)
|
||||
//
|
||||
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||
// ramp rather than restarting the new note, producing a silent note on the common
|
||||
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||
// the new note-on restarts the voice normally (falls through to start() below).
|
||||
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(note);
|
||||
return 0;
|
||||
}
|
||||
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void VoiceEngine::monoNoteOff(int note) {
|
||||
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||
if (note < 0 || note > 127) return;
|
||||
removeHeld(note);
|
||||
Voice& v = voices_[0];
|
||||
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||
// note) changes nothing audible.
|
||||
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||
|
||||
if (heldCount_ == 0) {
|
||||
v.release(); // last finger up: gate off (Trigger ignores this and plays through).
|
||||
return;
|
||||
}
|
||||
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||
if (monoTrigger_ == MonoTrigger::Legato) {
|
||||
v.retune(fb.note); // glide back, no re-attack
|
||||
return;
|
||||
}
|
||||
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||
// hard-cuts the sounding tone).
|
||||
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
v.setStartOrder(nextStartOrder_++);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||
if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play.
|
||||
|
||||
// Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice
|
||||
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||
// rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are
|
||||
// unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets
|
||||
// past the cap are refused.
|
||||
if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve &&
|
||||
activePreserveVoices() >= preserveVoiceCap_) {
|
||||
return kNoVoice;
|
||||
}
|
||||
|
||||
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||
// The takeover declick rides the STEAL restart too: start() self-gates on the voice being
|
||||
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||
// cut of a sounding tone as the mono retrig takeover.
|
||||
const std::size_t v = allocateVoice();
|
||||
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||
voices_[v].setStartOrder(nextStartOrder_++);
|
||||
return v;
|
||||
}
|
||||
|
||||
void VoiceEngine::noteOff(int note) {
|
||||
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||
std::size_t target = kNoVoice;
|
||||
std::uint64_t bestOrder = 0;
|
||||
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||
voices_[i].note() == note) {
|
||||
const std::uint64_t order = voices_[i].startOrder();
|
||||
if (target == kNoVoice || order > bestOrder) {
|
||||
target = i;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::allNotesOff() {
|
||||
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
if (v.active()) v.release();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::allSoundsOff() {
|
||||
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||
// bounded by the pool size.
|
||||
heldCount_ = 0;
|
||||
for (Voice& v : voices_) {
|
||||
v.hardStop();
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap.
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
AudioSample l = 0.0f, r = 0.0f;
|
||||
voice.renderFrameStereo(l, r);
|
||||
left[f] += l;
|
||||
right[f] += r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
if (v.active()) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
// voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and
|
||||
// stealing, the mono held-note stack, the two-tier panic, and the block render loops. The
|
||||
// per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its
|
||||
// present inline shape across this seam.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/play_params.h"
|
||||
#include "core/instrument/engine/voice.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, note-on
|
||||
// allocation with bounded voice stealing, note-off routing, and block rendering (sum of
|
||||
// voices).
|
||||
//
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine —
|
||||
// held by reference, never copies PCM). Every playback parameter rides on the sample; the
|
||||
// engine holds no parameters of its own beyond the voice-system config below.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are pre-sized
|
||||
// to at construction (off the audio thread), so note-on never allocates; 0 leaves them
|
||||
// pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes without a
|
||||
// re-attack). The engine's config is immutable — a mode/count change rebuilds the engine
|
||||
// off-thread through the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, poly at-cap steal) seeds the per-voice declick ramp (see
|
||||
// kDeclickDecay) so the hard cut doesn't click. start() self-gates on the voice being
|
||||
// active, so a fresh start never ramps. Default false keeps the bare core byte-identical
|
||||
// to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||
bool takeoverDeclick = false);
|
||||
|
||||
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
|
||||
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
|
||||
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
|
||||
std::size_t noteOn(int note, int velocity);
|
||||
|
||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
std::size_t activeVoiceCount() const;
|
||||
|
||||
std::size_t maxVoices() const { return voices_.size(); }
|
||||
|
||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||
|
||||
private:
|
||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, playable note in press order (top = most recent = the sounding note).
|
||||
// Re-pressing a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) —
|
||||
// no allocation on the audio thread. Velocity is kept per held note so a retrigger
|
||||
// fallback re-strikes at its original velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Push to the stack and take the voice over (legato retune, else a fresh start). Returns
|
||||
// 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (rejected before the
|
||||
// stack, which stores uint8). The Preserve cap is not applied in mono — a single voice
|
||||
// runs at most one shifter, inherently within any cap; applying it would wrongly drop a
|
||||
// Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const SampleData& sample_;
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1,5 +1,5 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
|
||||
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v8).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
@@ -11,7 +11,7 @@
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
@@ -26,98 +26,134 @@ namespace {
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component
|
||||
// blob, so both write zones identically. Always emits the CURRENT payload version (marker +
|
||||
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
|
||||
// marker precedes the zone count so any reader can detect record shape independent of the
|
||||
// envelope version (see sample_map.h).
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(z.loopOverride->start));
|
||||
putLE(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
||||
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||
// envelope's selection id (see the adoption rule in the header).
|
||||
struct PayloadRead {
|
||||
InstrumentParams params;
|
||||
std::string adoptedSampleId;
|
||||
};
|
||||
|
||||
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
|
||||
// trigger %-length + fades stay source frames/fraction. Order matches the header's
|
||||
// v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(out, doubleToBits(p.amp));
|
||||
}
|
||||
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||
// the two shapes cannot drift byte-for-byte.
|
||||
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
out.push_back(p.rootOverride ? 1 : 0);
|
||||
if (p.rootOverride) {
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||
}
|
||||
out.push_back(p.loopOverride ? 1 : 0);
|
||||
if (p.loopOverride) {
|
||||
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||
putLE(out, asU64(p.loopOverride->start));
|
||||
putLE(out, asU64(p.loopOverride->end));
|
||||
}
|
||||
out.push_back(p.startPoint ? 1 : 0);
|
||||
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||
}
|
||||
|
||||
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||
// shape independent of the envelope version (see component_state_io.h).
|
||||
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||
putLE(out, kParamsFormatMarker);
|
||||
putLE(out, kParamsPayloadVersion);
|
||||
putOverrides(out, p);
|
||||
|
||||
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||
const PlaySeconds& pp = p.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
|
||||
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
|
||||
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
|
||||
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
|
||||
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
|
||||
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
|
||||
putLE(out, doubleToBits(pp.adsr.attackSeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// Key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(p.keyTrack));
|
||||
// The velocity->amp transfer curve, appended last: 4-byte LE control-point count, then
|
||||
// per point velocity + amp as doubles (endpoints included, so N >= 2).
|
||||
const std::vector<VelocityPoint>& pts = p.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& pt : pts) {
|
||||
putLE(out, doubleToBits(pt.velocity));
|
||||
putLE(out, doubleToBits(pt.amp));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
|
||||
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
|
||||
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
|
||||
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
|
||||
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
|
||||
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||
// v8 single-record reader so the two can never disagree about field order.
|
||||
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
|
||||
// Read the velocity->amp curve tail into `p`. fromPoints repairs the X-order/endpoint
|
||||
// invariant defensively; a truncated read leaves the flat default.
|
||||
void readCurveTail(ByteReader& r, InstrumentParams& p) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge count
|
||||
// can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t i = 0; i < ptCount && r.ok; ++i) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
|
||||
if (r.ok) {
|
||||
p.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||
// no file and no bank entry.
|
||||
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||
// blobs carry seconds directly and need no rate.
|
||||
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||
PayloadRead out;
|
||||
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
bool adopted = false;
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
|
||||
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
|
||||
PerformanceZone z;
|
||||
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||
InstrumentParams p;
|
||||
std::string sampleId;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
z.lowNote = r.i32();
|
||||
z.highNote = r.i32();
|
||||
sampleId = r.str(idLen);
|
||||
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||
r.i32(); // highNote
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (hasOverride) p.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
@@ -125,113 +161,88 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
|
||||
// A/D) were written as frames -> divide by `projectRate` to reach seconds.
|
||||
// Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
|
||||
// in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
p.play.trigger.fadeInFrames = r.i64();
|
||||
p.play.trigger.fadeOutFrames = r.i64();
|
||||
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} else if (secondsPlay) {
|
||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
||||
// frames; read in the emit order.
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
readSecondsPlayTail(r, p);
|
||||
}
|
||||
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
|
||||
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
|
||||
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
|
||||
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
|
||||
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
|
||||
// read leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||
if (curveTail) readCurveTail(r, p);
|
||||
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||
// leave the seconds product defaults on p.play.
|
||||
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||
if (!adopted) {
|
||||
out.params = std::move(p);
|
||||
out.adoptedSampleId = std::move(sampleId);
|
||||
adopted = true;
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
if (!id.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = id;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
return map;
|
||||
// Read whichever payload shape follows: the CURRENT v8 single record, or a retired v1..v7
|
||||
// zone list (adopting zone one). An absent marker means v1 (a plain small zone count).
|
||||
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||
if (r.peekU32() == kParamsFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
}
|
||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
||||
if (pv < kParamsPayloadVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||
|
||||
readZonesPayload(r, map, projectRate);
|
||||
return map;
|
||||
PayloadRead out;
|
||||
InstrumentParams& p = out.params;
|
||||
const std::uint8_t hasRoot = r.u8();
|
||||
if (hasRoot) p.rootOverride = r.i32();
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
p.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) p.startPoint = r.i64();
|
||||
readSecondsPlayTail(r, p);
|
||||
p.keyTrack = bitsToDouble(r.u64());
|
||||
readCurveTail(r, p);
|
||||
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||
// the same degrade-don't-throw contract the zone ladder always had.
|
||||
if (!r.ok) return PayloadRead{};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||
// supersedes the envelope's selection id) lives here, once.
|
||||
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||
out.params = std::move(read.params);
|
||||
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// --- Combined component state --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
@@ -268,7 +279,7 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
// 1 = user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
|
||||
// to here. Wire shape per kSelectionRefsV10Version: entry count, then per entry id
|
||||
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||
// channelCount, displayName (length-prefixed; display-only).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
@@ -286,75 +297,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// v11 envelope addition (usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// Length-prefixed selection id (it precedes the params payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
putParamsPayload(out, state.params);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. See readZonesPayload for the guard.
|
||||
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one
|
||||
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
|
||||
// loaded capture with default parameters.
|
||||
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
if (!out.selectionId.empty()) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = out.selectionId;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
out.map.zones.push_back(std::move(z));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono
|
||||
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
|
||||
return out; // channelMode stays Mono
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
|
||||
// length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
|
||||
// length + id + payload starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // channelMode stays Mono, marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
|
||||
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
|
||||
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
if (version == kSelectionModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
|
||||
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||
// already-saved instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
if (version == kSelectionModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -363,21 +364,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out; // previewVelocity stays at the mid default
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
||||
version != kSelectionRefsV10Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
|
||||
version != kSelectionModeMarkerVelVoiceGainV8Version &&
|
||||
version != kSelectionModeMarkerVelVoiceV7Version &&
|
||||
version != kSelectionModeMarkerVelV6Version) {
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
|
||||
// than rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -392,7 +393,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
const std::uint8_t mt = r.u8();
|
||||
@@ -408,7 +409,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||
// than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
// mode/marker/velocity/voice fields from above; unity holds
|
||||
@@ -420,7 +421,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
@@ -428,8 +429,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||
// drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
// drops the rest (the selection/params behind it are unreadable anyway).
|
||||
if (version >= kSelectionRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
SampleRefEntry e;
|
||||
@@ -457,7 +458,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||
// holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
if (version >= kSelectionRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||
@@ -465,7 +466,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
applyPayload(out, readParamsPayload(r, projectRate));
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,91 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
|
||||
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||
// payload and the instrument's reader can never drift — without the extension having to
|
||||
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
|
||||
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
|
||||
// engine.
|
||||
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
|
||||
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
|
||||
// payload v1..v7) must be preserved exactly.
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||
// payload v1..v8) must be preserved exactly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
// --- The instance's parameter payload ----------------------------------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
|
||||
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
|
||||
// of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
// The one parameter set is the instrument's OWN state, serialized to the VST3
|
||||
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
|
||||
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
|
||||
// OWN version, so its record can grow without bumping the envelope version. Payload
|
||||
// extensions and envelope-field additions stay on independent axes that can never collide
|
||||
// on one version number.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
|
||||
// carries its OWN version, so the per-zone record can grow without bumping the envelope
|
||||
// version. Zone-record extensions and envelope-field additions stay on independent axes
|
||||
// that can never collide on one version number.
|
||||
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
|
||||
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
|
||||
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
|
||||
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
|
||||
// case. Their record shapes, in order:
|
||||
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
|
||||
// is v1.
|
||||
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
|
||||
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
|
||||
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
|
||||
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
|
||||
// (int64). The marker lets the reader detect record shape independent of the envelope.
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
|
||||
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
|
||||
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
|
||||
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
|
||||
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
|
||||
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
|
||||
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
|
||||
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
|
||||
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
|
||||
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
|
||||
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
|
||||
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
|
||||
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
|
||||
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
|
||||
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
|
||||
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
|
||||
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
|
||||
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
|
||||
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
|
||||
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
|
||||
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
|
||||
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
|
||||
// sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
|
||||
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
|
||||
// truncated/unknown/empty blob deserializes to an EMPTY map.
|
||||
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
|
||||
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
|
||||
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
|
||||
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
|
||||
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
|
||||
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
|
||||
// threaded into the v3 lift path at read time (a parameter, no baked constant).
|
||||
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
|
||||
// tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
|
||||
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
|
||||
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
|
||||
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
|
||||
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
|
||||
// frames tail) was never shipped and is intentionally not read.
|
||||
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
|
||||
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
|
||||
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
|
||||
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||
// (soft hits play louder than under the old linear velocity/127 map).
|
||||
//
|
||||
// These two functions serialize the ZONES only; the instrument's full component state is
|
||||
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
|
||||
// v8 (CURRENT WRITE FORMAT) is the one-parameter-set record: marker + version (== 8), then a
|
||||
// SINGLE record with no count, no key range and no sample id (the envelope's selection id is
|
||||
// the capture): 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte
|
||||
// hasLoopOverride + [1 byte loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set);
|
||||
// 1 byte hasStartPoint + 8-byte LE startPoint (iff set); the v5 play tail verbatim
|
||||
// (SECONDS); 8-byte LE keyTrack; then the velocity curve (count + points) as in v7.
|
||||
//
|
||||
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker. serializePerformance and
|
||||
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
|
||||
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
|
||||
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
|
||||
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
|
||||
// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
|
||||
// never shipped and is not read. The marker is a high sentinel no legitimate zone count
|
||||
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
|
||||
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
|
||||
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
|
||||
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
|
||||
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
|
||||
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
|
||||
// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
|
||||
// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
|
||||
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
|
||||
// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
|
||||
// leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||
// a reader detects record shape independent of the envelope version.
|
||||
inline constexpr std::uint32_t kParamsPayloadVersion = 8; // one parameter set, no zones
|
||||
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
|
||||
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
|
||||
// so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||
// seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3+) -------------
|
||||
//
|
||||
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and an instance with NO pick and NO
|
||||
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
|
||||
// sample #1.
|
||||
// --- Combined component state (VST3 setState/getState) -----------------------
|
||||
//
|
||||
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||
@@ -129,51 +95,53 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
|
||||
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
|
||||
// path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
|
||||
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the
|
||||
// minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
|
||||
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||
// version). The instance guid is the only v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
|
||||
// PARALLEL track owns zone-record extension under its own versioning — the two version
|
||||
// numbers are independent axes; do NOT bump the zones-payload version for an envelope
|
||||
// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
|
||||
// corrupt blob) falls back to the field's default rather than silencing the instance.
|
||||
// CURRENT params payload (its own self-describing version). The envelope grows fields on an
|
||||
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
|
||||
//
|
||||
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
|
||||
// blob) falls back to the field's default rather than silencing the instance.
|
||||
//
|
||||
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||
// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
|
||||
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
|
||||
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
|
||||
// re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
|
||||
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
|
||||
// publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, params}: implicit mode.
|
||||
// * v7 blob -> unity master gain.
|
||||
// * v6 blob -> voice defaults.
|
||||
// * v5 blob -> no velocity byte.
|
||||
// * v4 blob -> no marker.
|
||||
// * v3 blob -> no channel mode.
|
||||
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
|
||||
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
|
||||
// instance re-applying a stale assign_request the user already got and then manually
|
||||
// changed away from: on re-open the instance re-reads the pending request, and only a
|
||||
// generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
|
||||
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
|
||||
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
|
||||
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
|
||||
//
|
||||
// WHY THE ASSIGNMENT MARKER PERSISTS. The last-consumed assignment generation stops a
|
||||
// re-opened instance re-applying a stale assign_request the user already got and then
|
||||
// manually changed away from: on re-open the instance re-reads the pending request, and only
|
||||
// a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written
|
||||
// to the bank — the extension owns the assign_request key; the instrument only tracks what
|
||||
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
|
||||
// with no velocity byte lifts to this, audible-but-not-hot.
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written to
|
||||
// the bank. The preview-trigger velocity default is a mid MIDI velocity: an older blob with
|
||||
// no velocity byte lifts to this, audible-but-not-hot.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
std::string selectionId; // the loaded capture; "" = no pick
|
||||
InstrumentParams params; // the ONE parameter set governing it
|
||||
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||
@@ -181,26 +149,25 @@ struct ComponentState {
|
||||
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
|
||||
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
|
||||
// user's chosen strike velocity across saves.
|
||||
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
|
||||
// the Sample-view preview button retains the user's chosen strike velocity across saves.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
|
||||
// exactly, so an older blob lifting to these plays byte-identically.
|
||||
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
|
||||
// reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
|
||||
// byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
|
||||
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
|
||||
// unity reproduces pre-master-gain output byte-identically.
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
|
||||
// voice sum — never per voice. Default unity reproduces pre-master-gain output
|
||||
// byte-identically.
|
||||
double masterGainLinear = 1.0;
|
||||
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||
// bank sample this instance plays (see the SampleRefs block above). setState decodes
|
||||
// straight from these; NO bridge/extension read is required for playback. A pre-v10
|
||||
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
|
||||
// once (then re-saves self-contained).
|
||||
// bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||
@@ -214,7 +181,7 @@ inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
|
||||
|
||||
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||
@@ -222,36 +189,36 @@ inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
|
||||
|
||||
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||
// in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||
// {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
|
||||
|
||||
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
|
||||
|
||||
// Selection + zones + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
// Selection + params + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
|
||||
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
|
||||
|
||||
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, params}.
|
||||
inline constexpr std::uint32_t kSelectionV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||
@@ -266,16 +233,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
|
||||
// the instrument, never written back to the bank) — a single string id. serialize/
|
||||
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
|
||||
// without breaking already-saved instances.
|
||||
// The original v1 instance state was which bank sample it plays — a single string id.
|
||||
//
|
||||
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||
// for the v1->v3 back-compat lift in deserializeComponentState.
|
||||
// for the v1 back-compat lift in deserializeComponentState.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
@@ -286,5 +250,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
#include <algorithm> // std::min
|
||||
#include <algorithm> // std::remove_if
|
||||
#include <cassert> // assert
|
||||
#include <utility> // std::move
|
||||
|
||||
@@ -34,25 +34,6 @@ SelectedSample distill(const Sample& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
|
||||
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
|
||||
// (start). The bank is never mutated.
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
|
||||
return rz;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
@@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map) {
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
|
||||
std::vector<std::string> ids;
|
||||
const auto addUnique = [&ids](const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
for (const std::string& have : ids) {
|
||||
if (have == id) return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
};
|
||||
addUnique(selectionId);
|
||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
||||
if (!selectionId.empty()) ids.push_back(selectionId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -214,10 +186,10 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
||||
return out;
|
||||
}
|
||||
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||
DecodedZonePcm out;
|
||||
DecodedPcm out;
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
@@ -230,7 +202,7 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
@@ -240,7 +212,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
if (f < 0.0) f = 0.0;
|
||||
return static_cast<std::int64_t>(f + 0.5);
|
||||
};
|
||||
ZonePlayParams out;
|
||||
PlayParams out;
|
||||
out.playMode = stored.playMode;
|
||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||
@@ -256,130 +228,61 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
||||
// --- The one parameter set ----------------------------------------------------
|
||||
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
|
||||
ResolvedCapture rs;
|
||||
rs.relativePath = ref.relativePath;
|
||||
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rs.keyTrack = params.keyTrack;
|
||||
rs.velocityCurve = params.velocityCurve;
|
||||
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
|
||||
// The bank is never mutated.
|
||||
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
|
||||
rs.startFrame = params.startPoint ? *params.startPoint : 0;
|
||||
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
|
||||
return rs;
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
|
||||
if (!sel) return std::nullopt;
|
||||
return resolveCapture(*sel, params);
|
||||
}
|
||||
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params) {
|
||||
const SelectedSample* ref = findRef(refs, selectionId);
|
||||
if (ref == nullptr) return std::nullopt;
|
||||
return resolveCapture(*ref, params);
|
||||
}
|
||||
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
|
||||
SampleData data;
|
||||
data.frames = std::move(frames);
|
||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(framesR);
|
||||
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
|
||||
assert(decoded.sampleRate > 0 &&
|
||||
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
|
||||
data.frames = std::move(decoded.monoFrames);
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
|
||||
data.framesR = std::move(decoded.framesR);
|
||||
}
|
||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
||||
data.sampleRate = sampleRate;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
||||
data.play = resolvePlay(play, data.sampleRate);
|
||||
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
data.sampleRate = decoded.sampleRate;
|
||||
data.rootNote = resolved.rootNote;
|
||||
data.loop = resolved.loop;
|
||||
data.startFrame = resolved.startFrame;
|
||||
data.keyTrack = resolved.keyTrack;
|
||||
data.velocityCurve = resolved.velocityCurve;
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(resolved.play, data.sampleRate);
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Performance map ---------------------------------------------------------
|
||||
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// A sample lives in exactly one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
found = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
|
||||
continue;
|
||||
}
|
||||
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
|
||||
// fold — so the bank path and refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map) {
|
||||
ResolvedPerformance out;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
||||
if (selectedId.empty() || map.zones.empty()) return false;
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
||||
// there, so the map is left exactly as authored.
|
||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
||||
}
|
||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
||||
}
|
||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
||||
map.zones.resize(keptCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded) {
|
||||
Keymap km;
|
||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
||||
if (decoded[i].monoFrames.empty()) continue;
|
||||
SampleData data;
|
||||
data.frames = decoded[i].monoFrames;
|
||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||
if (!decoded[i].framesR.empty() &&
|
||||
decoded[i].framesR.size() == data.frames.size()) {
|
||||
data.framesR = decoded[i].framesR;
|
||||
}
|
||||
assert(decoded[i].sampleRate > 0 &&
|
||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
||||
data.sampleRate = decoded[i].sampleRate;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
zone.lowNote = zones[i].lowNote;
|
||||
zone.highNote = zones[i].highNote;
|
||||
zone.rootNote = zones[i].rootNote;
|
||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
||||
zone.sampleIndex = sampleIndex;
|
||||
km.zones.push_back(zone);
|
||||
}
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#pragma once
|
||||
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
|
||||
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
|
||||
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
|
||||
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
|
||||
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
|
||||
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
|
||||
// here. Links bank_book, wav_codec, and sampler_core (all pure).
|
||||
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
|
||||
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
|
||||
// NOT the voice engine: the build's product is plain SampleData.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -12,7 +13,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
@@ -29,7 +30,7 @@ struct SelectedSample {
|
||||
int rootNote = 60; // defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
|
||||
// the GA channel-mode auto-default skips it
|
||||
// the channel-mode auto-default skips it
|
||||
};
|
||||
|
||||
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
|
||||
@@ -60,8 +61,6 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
||||
// Consequence: a sample deleted from the bank no longer silences an instance that carries
|
||||
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
|
||||
// the file yields the defined no-play).
|
||||
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
|
||||
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
@@ -74,10 +73,9 @@ using SampleRefs = std::vector<SampleRefEntry>;
|
||||
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||
|
||||
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
||||
// sampleId, de-duplicated, selection first then map order.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
// Every bank sample id this instance plays. One capture = at most one id; the list form is
|
||||
// kept because the refs-table helpers below are id-set operations.
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
|
||||
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
|
||||
@@ -123,10 +121,10 @@ struct BankChoice {
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
|
||||
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
|
||||
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
|
||||
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
|
||||
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
|
||||
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
|
||||
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
@@ -136,14 +134,14 @@ std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleav
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
// --- Stored (wall-clock SECONDS) play params ----------------------------------
|
||||
//
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
// keymap build. Quantities anchored to the source file's timeline (start point, loop
|
||||
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
|
||||
// unchanged (TriggerParams reused verbatim).
|
||||
// build. Quantities anchored to the source file's timeline (start point, loop points,
|
||||
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||
// (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
@@ -162,10 +160,10 @@ struct PitchEnvSeconds {
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
|
||||
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
|
||||
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
|
||||
struct ZonePlaySeconds {
|
||||
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||
// from the engine-facing PlayParams (frames).
|
||||
struct PlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
@@ -173,46 +171,28 @@ struct ZonePlaySeconds {
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
|
||||
// unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
|
||||
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
|
||||
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
|
||||
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
|
||||
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
|
||||
// picked single capture plays under the same default engine as a zone would. Resolves the
|
||||
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (the instrument's OWN state) ---------------
|
||||
// --- The instrument's ONE parameter set (its OWN state) -----------------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. A performance
|
||||
// choice, so it lives in the instrument (VST3 component state), never written back to the
|
||||
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
|
||||
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
|
||||
// + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
|
||||
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
|
||||
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them per zone
|
||||
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
|
||||
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
|
||||
// the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
// One loaded capture, one set of playback parameters governing it across the whole
|
||||
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
|
||||
// never written back to the bank. Pure value type: names no sample (the ComponentState's
|
||||
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
|
||||
// the pure build stitches the decoded frames + this set into one SampleData.
|
||||
//
|
||||
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
|
||||
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them without
|
||||
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
|
||||
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
|
||||
// ResolvedCapture.
|
||||
struct InstrumentParams {
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
|
||||
@@ -223,116 +203,80 @@ struct PerformanceZone {
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
|
||||
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
|
||||
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
|
||||
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
|
||||
// in Voice::start.
|
||||
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
|
||||
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
|
||||
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
|
||||
// Voice::start.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
|
||||
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
|
||||
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
|
||||
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
|
||||
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
|
||||
// to exactly these defaults on read.
|
||||
ZonePlaySeconds play;
|
||||
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
|
||||
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
|
||||
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
|
||||
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
||||
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||
PlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution — first zone in order wins (mirrors the core's first-match
|
||||
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
|
||||
//
|
||||
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||
// control edit. Loading a different sample used to change only the selection id, leaving
|
||||
// the previous sample's full-range zone in the map — and since zone resolution is
|
||||
// first-match in order, that stale zone shadowed every later one forever: the engine kept
|
||||
// playing the old sample while the editor drew the new one's zone. This function is called
|
||||
// at every selection-change site so the zone the editor draws is the zone the engine plays.
|
||||
//
|
||||
// Rules (order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
|
||||
// first-match order is load-bearing there — untouched, false (the Sample face never
|
||||
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
|
||||
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
|
||||
// (params preserved); drop the rest. A selection with no zone yet empties the map.
|
||||
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
|
||||
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
|
||||
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
|
||||
// an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
|
||||
// (which holds optional overrides) — this is the parameter set folded against the capture.
|
||||
struct ResolvedCapture {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
|
||||
double keyTrack = 1.0;
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
PlaySeconds play; // stored SECONDS; resolved to frames at build
|
||||
};
|
||||
|
||||
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
|
||||
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
|
||||
// cleanly — not an error, not silence for the whole map — and reported here so the editor
|
||||
// can flag/prune it.
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
|
||||
// drift.
|
||||
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
|
||||
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
|
||||
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
|
||||
// -> empty result.
|
||||
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
|
||||
// empty selection, or a stale id -> nullopt.
|
||||
//
|
||||
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
|
||||
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
|
||||
// (both share foldZone, so the drift test keeps the shared fold honest).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
|
||||
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
|
||||
// resolveCapture, so the drift test keeps the shared fold honest).
|
||||
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
|
||||
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
|
||||
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map);
|
||||
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
|
||||
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
|
||||
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||
const std::string& selectionId,
|
||||
const InstrumentParams& params);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
|
||||
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
|
||||
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
|
||||
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
|
||||
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
|
||||
struct DecodedZonePcm {
|
||||
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
|
||||
struct DecodedPcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
|
||||
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
|
||||
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
|
||||
// `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (average all source channels).
|
||||
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
|
||||
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
// Empty/zero-channel input -> empty frames (caller plays silence).
|
||||
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
|
||||
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
|
||||
// plays across the whole keyboard, repitched from the effective root. A second channel is
|
||||
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
|
||||
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
|
||||
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
|
||||
// unplayable SampleData (silence, never a crash).
|
||||
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
|
||||
|
||||
// The ComponentState envelope + params-payload binary codec lives in component_state_io.h:
|
||||
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
|
||||
// so both artifacts share the codec while only the VST links the voice engine.
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
|
||||
// The Browse modal is a full-window sheet drawn over the Sample face, so it reuses that
|
||||
// face's chrome metrics rather than minting its own — a divergent title height or pad would
|
||||
// make the sheet visibly not line up with what it covers.
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight
|
||||
#include "core/instrument/ui/sample_chrome.h" // kNavButtonWidth (the Back button's slot)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
|
||||
// window within min/max bounds. A title row sits over the curve box. The curve box rect
|
||||
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
|
||||
// formula, so the popup editor and the Zone-panel inline editor share coordinates.
|
||||
// formula.
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp to non-negative extents so a degenerate view can't produce inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
|
||||
out.canvas = Rect::ltrb(0, titleH, cw, ch);
|
||||
|
||||
// Button inset from the canvas top-left, clamped so it never overhangs a small view.
|
||||
const int bx = out.canvas.x + kButtonMargin;
|
||||
const int by = out.canvas.y + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
|
||||
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
|
||||
if (y < layout.canvas.y) return -1;
|
||||
if (y >= layout.canvas.bottom()) return -1;
|
||||
const int index = (y - layout.canvas.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
// --- Keymap editor -----------------------------------------------------------
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
KeymapEditorLayout out;
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
const int canvasW = std::max(0, canvas.width);
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.x, canvas.right() - splitW);
|
||||
|
||||
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
|
||||
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
|
||||
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
|
||||
out.addZoneButton =
|
||||
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
|
||||
out.zonePanel.y + addH);
|
||||
|
||||
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
|
||||
out.zonePanel.right(), out.zonePanel.bottom());
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.sampleList.y + index * kSampleRowHeight;
|
||||
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
|
||||
top + kSampleRowHeight);
|
||||
}
|
||||
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
const Rect& list = layout.sampleList;
|
||||
if (x < list.x || x >= list.right()) return -1;
|
||||
if (y < list.y || y >= list.bottom()) return -1;
|
||||
const int index = (y - list.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
const Rect r = keymapSampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
|
||||
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
|
||||
top + kZoneRowHeight);
|
||||
}
|
||||
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
||||
if (zoneCount <= 0) return ZoneHit{};
|
||||
const Rect& area = layout.zoneRowArea;
|
||||
if (x < area.x || x >= area.right()) return ZoneHit{};
|
||||
if (y < area.y || y >= area.bottom()) return ZoneHit{};
|
||||
const int index = (y - area.y) / kZoneRowHeight;
|
||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom()) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
|
||||
// order 0..6; a click left of the leftmost is the label ("select").
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
ZoneField::kDelete,
|
||||
};
|
||||
const int slots = 7;
|
||||
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
|
||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
||||
return ZoneHit{index, fields[slot]};
|
||||
}
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kHeroMinHeight = 150; // elastic hero's floor
|
||||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
||||
constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip)
|
||||
|
||||
// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
|
||||
} // namespace
|
||||
|
||||
// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
|
||||
// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
|
||||
// window too short for the floor keeps the hero at its floor and clips lower bands.
|
||||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
||||
SampleBands b;
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
b.title = Rect::ltrb(0, 0, w, titleH);
|
||||
// Two nav buttons right-anchored in the title band (Browse then Zone).
|
||||
const int navTop = 2;
|
||||
const int navBot = (std::max)(navTop, titleH - 2);
|
||||
const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot);
|
||||
const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot);
|
||||
b.navBrowse = browse;
|
||||
b.navZone = zone;
|
||||
|
||||
int deckTop = h - kPad - deckH;
|
||||
int clusterTop = deckTop - kClusterHeight - 4;
|
||||
int heroBottom = clusterTop - 4;
|
||||
if (heroBottom - titleH < kHeroMinHeight) {
|
||||
heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below
|
||||
clusterTop = heroBottom + 4;
|
||||
deckTop = clusterTop + kClusterHeight + 4;
|
||||
}
|
||||
b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom);
|
||||
b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight);
|
||||
b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
|
||||
ClusterRects r;
|
||||
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
|
||||
const int stripBot = stripTop + kStripBandHeight;
|
||||
const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2;
|
||||
r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop,
|
||||
chanMono.x - kPad, curveTop + kCurveBtnSize);
|
||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
||||
r.curveBtn.x - kPad, stripBot);
|
||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + knobSize);
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom());
|
||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
||||
r.velCell.x - kPad, stripBot);
|
||||
r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot);
|
||||
return r;
|
||||
}
|
||||
|
||||
ChannelToggleRects channelToggleRects(const Rect& area) {
|
||||
const int top = area.y + (area.height - kChanSegH) / 2;
|
||||
const int right = area.right() - kPad;
|
||||
const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH);
|
||||
const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH);
|
||||
return {mono, stereo};
|
||||
}
|
||||
|
||||
Rect zoneContentArea(int w, int h) {
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
return Rect::ltrb(0, titleH, w, h);
|
||||
}
|
||||
|
||||
Rect zoneBackRect(int w, int h) {
|
||||
return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad,
|
||||
(std::max)(2, (std::min)(kTitleHeight, h) - 2));
|
||||
}
|
||||
|
||||
Rect zoneAddRect(const Rect& content) {
|
||||
return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96,
|
||||
content.y + 4 + 20);
|
||||
}
|
||||
|
||||
Rect zoneDeleteRect(const Rect& addR) {
|
||||
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
|
||||
}
|
||||
|
||||
// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
|
||||
Rect zonesStripArea(const Rect& content) {
|
||||
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
|
||||
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
|
||||
stripTop + kStripBandHeight);
|
||||
}
|
||||
|
||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
|
||||
Rect noteEntryFieldsArea(const Rect& content) {
|
||||
const int stripBottom = zonesStripArea(content).bottom();
|
||||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
|
||||
return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18);
|
||||
}
|
||||
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
||||
if (f < 0 || f > 2 || fields.width <= 0) return Rect{};
|
||||
const int segW = fields.width / 3;
|
||||
const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
||||
const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW;
|
||||
return Rect::ltrb(left, fields.y, right, fields.bottom());
|
||||
}
|
||||
|
||||
Rect zonesControlPanel(const Rect& content) {
|
||||
const Rect strip = zonesStripArea(content);
|
||||
const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||||
return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad,
|
||||
content.bottom() - 4);
|
||||
}
|
||||
|
||||
// Top-anchored; reserves a column at the panel's right for the curve-preview button so
|
||||
// no deck row starts inside it.
|
||||
Rect zonesDeckArea(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
|
||||
}
|
||||
|
||||
Rect zonesCurveButton(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -1,8 +1,9 @@
|
||||
// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
|
||||
// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
|
||||
// live here so they can be unit-tested outside the DAW.
|
||||
|
||||
#pragma once
|
||||
// editor_geometry.h — the shared geometry vocabulary for the VST3 editor's pure modules:
|
||||
// the one concrete `Rect` (aliased from core/ui) and its half-open `contains()`. Every
|
||||
// instrument UI module speaks these types, so they live in one place rather than each
|
||||
// module reaching into core/ui separately. The Sample face's own layout lives in
|
||||
// sample_bands (the band-stack allocator) and the per-band modules.
|
||||
|
||||
#include "core/ui/rect.h"
|
||||
|
||||
@@ -11,174 +12,4 @@ namespace reasampler::instrument::ui {
|
||||
using Rect = ::reasampler::ui::Rect;
|
||||
using ::reasampler::ui::contains;
|
||||
|
||||
// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
|
||||
// never yields a region spilling outside the surface.
|
||||
struct EditorLayout {
|
||||
Rect titleBar;
|
||||
Rect button;
|
||||
Rect canvas;
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the editor's top-level regions. Pure.
|
||||
EditorLayout layoutEditor(int w, int h);
|
||||
|
||||
enum class HitTarget {
|
||||
kNone,
|
||||
kButton,
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against a layout.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list ---------------------------------------------------
|
||||
//
|
||||
// A vertical stack of fixed-height rows below the title bar; clicking a row selects that
|
||||
// sample. Pure geometry only — the shell draws names and routes the click.
|
||||
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
|
||||
// beyond what the canvas can show are still computed (the shell clips at paint time); a
|
||||
// negative index yields an empty rect.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
|
||||
// the list.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor ------------------------------------------------------------
|
||||
//
|
||||
// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
|
||||
// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
|
||||
// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
|
||||
// carries nudge/delete mini-buttons (LICE has no native numeric entry field).
|
||||
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
|
||||
|
||||
// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base;
|
||||
Rect sampleList; // LEFT column
|
||||
Rect zonePanel; // RIGHT column
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// Bank-sample row a click lands on inside the left list, or -1 outside it.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields: a label on the left, then seven fixed-width
|
||||
// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
|
||||
// means the click missed a control (e.g. the label) — the shell may still treat that as
|
||||
// "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
kLowUp,
|
||||
kHighDown,
|
||||
kHighUp,
|
||||
kRootDown,
|
||||
kRootUp,
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// Which zone row (or -1) and which field within it a click landed on. A click on
|
||||
// "Add Zone" is reported separately by addZoneHitTest.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
|
||||
// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
|
||||
// click left of those slots is {index, kZoneNone} (the label area — "select").
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample / Zone face layout ------------------------------------------------
|
||||
//
|
||||
// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
|
||||
// both derive every rect from these formulas so they can never drift; the shell only
|
||||
// draws + routes. The Browse-modal layout lives in browser_scroll (its search box
|
||||
// height feeds it).
|
||||
|
||||
inline constexpr int kPad = 8;
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||||
|
||||
// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
|
||||
// HERO (absorbs all height left after the fixed bands, floored), the root+preview
|
||||
// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
|
||||
// window shorter than the hero floor clips the lower bands past the window bottom.
|
||||
struct SampleBands {
|
||||
Rect title;
|
||||
Rect navBrowse;
|
||||
Rect navZone;
|
||||
Rect hero; // waveform + envelope overlay
|
||||
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
|
||||
Rect deck;
|
||||
};
|
||||
SampleBands computeSampleBands(int w, int h, int deckH);
|
||||
|
||||
// Cluster sub-rects: the root strip keeps the left side at remainder width; the right
|
||||
// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
|
||||
// Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
|
||||
// depend on knob_deck.
|
||||
struct ClusterRects {
|
||||
Rect rootStrip;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the curve-preview popup
|
||||
};
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
|
||||
|
||||
// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
|
||||
struct ChannelToggleRects {
|
||||
Rect mono;
|
||||
Rect stereo;
|
||||
};
|
||||
ChannelToggleRects channelToggleRects(const Rect& area);
|
||||
|
||||
// Zone-view content area: the whole window below the title band.
|
||||
Rect zoneContentArea(int w, int h);
|
||||
|
||||
// Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
|
||||
Rect zoneBackRect(int w, int h);
|
||||
|
||||
// "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
|
||||
// when a zone is selected).
|
||||
Rect zoneAddRect(const Rect& content);
|
||||
Rect zoneDeleteRect(const Rect& addR);
|
||||
|
||||
// Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
|
||||
// horizontally.
|
||||
Rect zonesStripArea(const Rect& content);
|
||||
|
||||
// Numeric-entry field row area inside the Zones legend, and the rect of field `f`
|
||||
// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
|
||||
// index yields an empty rect.
|
||||
Rect noteEntryFieldsArea(const Rect& content);
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f);
|
||||
|
||||
// Per-zone parameter panel below the strip + legend, running to the content bottom; the
|
||||
// knob-deck area within it (a right column reserved for the curve-preview button); and
|
||||
// that button's rect (right-anchored at the panel top).
|
||||
Rect zonesControlPanel(const Rect& content);
|
||||
Rect zonesDeckArea(const Rect& content);
|
||||
Rect zonesCurveButton(const Rect& content);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -15,7 +15,7 @@ int clampNote(int n) {
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
|
||||
// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
|
||||
// span's left uses floor(low) and its right uses floor(high+1), tiling adjacent spans
|
||||
// without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
@@ -44,31 +44,19 @@ EmbedLayout layoutEmbed(int w, int h) {
|
||||
return out;
|
||||
}
|
||||
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote) {
|
||||
const Rect& band = layout.keymap;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
if (lo > hi) lo = hi; // defensive: a malformed span collapses rather than inverts
|
||||
|
||||
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
|
||||
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
|
||||
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
|
||||
}
|
||||
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y) {
|
||||
if (zoneCount <= 0 || zones == nullptr) return -1;
|
||||
if (!contains(layout.keymap, x, y)) return -1;
|
||||
// First covering zone in draw order wins (first-match, mirroring the core's resolve).
|
||||
for (int i = 0; i < zoneCount; ++i) {
|
||||
const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote);
|
||||
if (contains(r, x, y)) return i;
|
||||
}
|
||||
return -1; // on the band but on an uncovered key
|
||||
}
|
||||
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level) {
|
||||
const Rect& band = layout.levelBand;
|
||||
if (band.width <= 0 || band.height <= 0) return Rect{};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// bitmap + mouse coords) into these functions.
|
||||
//
|
||||
// A single compact band REAPER draws inline in the track/mixer control panel via the
|
||||
// Cockos embedded-UI surface: each performance zone as a horizontal segment across the
|
||||
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
|
||||
// at the bottom. Interaction is zone selection only — no editing.
|
||||
// Cockos embedded-UI surface: the loaded capture across the keyboard span (MIDI 0..127
|
||||
// mapped to the strip width) with its root marked, plus a thin activity level band at the
|
||||
// bottom. Read-only — the strip displays, it never edits.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -19,18 +19,10 @@ inline constexpr int kEmbedKeyCount = 128;
|
||||
inline constexpr int kEmbedLevelBandHeight = 4;
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6;
|
||||
|
||||
// One zone rendered on the strip: its inclusive MIDI key range — the minimal projection
|
||||
// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
|
||||
// low <= high; layout clamps defensively regardless.
|
||||
struct EmbedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
};
|
||||
|
||||
// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
|
||||
// the surface.
|
||||
struct EmbedLayout {
|
||||
Rect keymap; // top: zone-segment band
|
||||
Rect keymap; // top: keyboard-span band
|
||||
Rect levelBand; // bottom: level/activity indicator
|
||||
};
|
||||
|
||||
@@ -39,16 +31,11 @@ struct EmbedLayout {
|
||||
// kEmbedKeymapMinHeight); the keymap takes the rest.
|
||||
EmbedLayout layoutEmbed(int w, int h);
|
||||
|
||||
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
|
||||
// overlap. Notes clamp to [0,127] and low clamps to <= high.
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
|
||||
// zones overlap on a key, the first covering zone in order wins — mirroring the sampler
|
||||
// core's first-match Keymap::resolve, so selection agrees with playback.
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y);
|
||||
// Horizontal sub-rect of the keymap band for the inclusive key span [lowNote, highNote].
|
||||
// Spans the half-open pixel range so adjacent spans tile without a gap or overlap. Notes
|
||||
// clamp to [0,127] and low clamps to <= high. The loaded capture uses the full span; a
|
||||
// single-key span (low == high) is the root marker.
|
||||
Rect keySpanRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
|
||||
// whose width is level * band width.
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
|
||||
//
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
|
||||
// every paint), so a node drag and a slider edit are two views on one source of truth.
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the one
|
||||
// parameter set every paint), so a node drag and a slider edit are two views on one source of
|
||||
// truth.
|
||||
//
|
||||
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
|
||||
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
|
||||
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
|
||||
// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
|
||||
// the polyline plus a handle at each node (envelope_edit does the hit-test).
|
||||
// param_slider. The shell packs the one parameter set's AdsrSeconds/TriggerParams into
|
||||
// AmpEnvelope and draws the polyline plus a handle at each node (envelope_edit does the
|
||||
// hit-test).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ int clampNote(int n) {
|
||||
}
|
||||
|
||||
// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
@@ -62,41 +62,6 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
return clampNote(note);
|
||||
}
|
||||
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
}
|
||||
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
|
||||
const Rect bar = zoneBarRect(layout, lowNote, highNote);
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width;
|
||||
// A narrow bar has no body: split at the midpoint, low edge wins the tie.
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.x + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
}
|
||||
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
|
||||
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
|
||||
return ZoneGrab::kBody;
|
||||
}
|
||||
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || lows == nullptr || highs == nullptr) return ZoneBarHit{};
|
||||
if (!contains(layout.keys, x, y)) return ZoneBarHit{};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const ZoneGrab g = zoneGrabAt(layout, lows[i], highs[i], x, y);
|
||||
if (g != ZoneGrab::kNone) return ZoneBarHit{i, g};
|
||||
}
|
||||
return ZoneBarHit{}; // on the band but on no bar
|
||||
}
|
||||
|
||||
bool isNaturalKey(int note) {
|
||||
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
|
||||
static constexpr bool kNatural[12] = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
|
||||
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
|
||||
// and marshals mouse events into these functions.
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the editor's keyboard strip.
|
||||
// Mirror of embed_strip/mode_switch; the shell draws and marshals mouse events into these
|
||||
// functions.
|
||||
//
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
|
||||
// embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
|
||||
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
|
||||
// bar with edge-grab resize handles + a body move-handle).
|
||||
// embed_strip uses). The loaded capture responds across that whole span, so the strip's
|
||||
// job is the root marker: click a key, or drag the marker, to set the root.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -17,10 +16,6 @@ namespace reasampler::instrument::ui {
|
||||
// stay independent.
|
||||
inline constexpr int kStripKeyCount = 128;
|
||||
|
||||
// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
|
||||
// body move-handle (both edges win their halves).
|
||||
inline constexpr int kStripEdgeGrabWidth = 6;
|
||||
|
||||
// The keys band takes the whole strip area today; clamped so a degenerate size never
|
||||
// yields an inverted rect.
|
||||
struct StripLayout {
|
||||
@@ -37,44 +32,15 @@ int keyLeftX(const StripLayout& layout, int note);
|
||||
// Half-open rect of a single key `note`, clamped to [0,127].
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// Root-marker rect for the single-capture fast path; equivalent to
|
||||
// keyRect(layout, rootNote) but named so the intent reads at the call site.
|
||||
// Root-marker rect; equivalent to keyRect(layout, rootNote) but named so the intent reads
|
||||
// at the call site.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// MIDI note a point (x, y) lands on, or -1 outside the keys band.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
|
||||
// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
|
||||
// moves the whole span, kNone means the grab missed the bar.
|
||||
enum class ZoneGrab {
|
||||
kNone,
|
||||
kLowEdge,
|
||||
kHighEdge,
|
||||
kBody,
|
||||
};
|
||||
|
||||
// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body); the low edge wins a tie at the exact
|
||||
// midpoint.
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
|
||||
|
||||
// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
|
||||
// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
|
||||
// order wins.
|
||||
struct ZoneBarHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneGrab grab = ZoneGrab::kNone;
|
||||
};
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
|
||||
// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
|
||||
// edges with the same delta to preserve span), and root-marker drag.
|
||||
// clamped to [0,127]. The one arithmetic behind the root-marker drag.
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
|
||||
|
||||
// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// sample_bands.cpp — see sample_bands.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight) {
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
const int deckH = std::max(0, deckHeight);
|
||||
|
||||
SampleBands b;
|
||||
const int chromeH = std::min(kTitleHeight + kChromeRowHeight, ch);
|
||||
b.chrome = Rect::ltrb(0, 0, cw, chromeH);
|
||||
|
||||
// Decks are bottom-anchored so the deck row sits on the window edge at any height; the
|
||||
// waveform absorbs whatever is left. When that leaves less than the two-lane floor the
|
||||
// FLOOR WINS and the deck band is pushed past the window bottom (clipped) rather than
|
||||
// squeezing the waveform into an unreadable sliver.
|
||||
int deckTop = ch - kPad - deckH;
|
||||
int waveTop = chromeH + kBandGap;
|
||||
int waveBottom = deckTop - kBandGap;
|
||||
if (waveBottom - waveTop < kWaveformMinHeight) {
|
||||
waveBottom = waveTop + kWaveformMinHeight;
|
||||
deckTop = waveBottom + kBandGap;
|
||||
}
|
||||
|
||||
b.waveform = Rect::ltrb(kPad, waveTop, std::max(kPad, cw - kPad), waveBottom);
|
||||
b.decks = Rect::ltrb(kPad, deckTop, std::max(kPad, cw - kPad), deckTop + deckH);
|
||||
return b;
|
||||
}
|
||||
|
||||
WaveformLanes waveformLanes(const Rect& waveform, bool stereo) {
|
||||
WaveformLanes lanes;
|
||||
if (waveform.empty()) return lanes;
|
||||
if (!stereo) {
|
||||
lanes.upper = waveform; // one lane; `lower` stays empty
|
||||
return lanes;
|
||||
}
|
||||
// Split the usable height evenly, giving the seam to the gap. An odd remainder goes to
|
||||
// the upper (left) lane so the two lanes never disagree about the seam row.
|
||||
const int usable = std::max(0, waveform.height - kLaneGap);
|
||||
const int lowerH = usable / 2;
|
||||
const int upperH = usable - lowerH;
|
||||
const int upperBottom = waveform.y + upperH;
|
||||
lanes.upper = Rect::ltrb(waveform.x, waveform.y, waveform.right(), upperBottom);
|
||||
lanes.lower = Rect::ltrb(waveform.x, upperBottom + kLaneGap, waveform.right(),
|
||||
waveform.bottom());
|
||||
return lanes;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
// sample_bands.h — THE band-stack allocator for the Sample face: the one module that owns
|
||||
// the editor's vertical inventory. Three bands, top to bottom — CHROME (toolbar + control
|
||||
// row), WAVEFORM (elastic, sized to hold two stacked channel lanes), DECKS (the knob-deck
|
||||
// row). Everything else in the editor fills a band it is handed; nothing else allocates
|
||||
// vertical space, so a band's owner can re-lay its interior without moving its neighbours.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Shared outer inset every band honours horizontally.
|
||||
inline constexpr int kPad = 8;
|
||||
|
||||
// Chrome band: the toolbar row (title + nav) stacked over the control row (piano strip,
|
||||
// preview, velocity knob, curve button, channel toggle). sample_chrome partitions it.
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kChromeRowHeight = 52;
|
||||
|
||||
// Waveform band floor: two stacked lanes plus the seam between them. The band never shrinks
|
||||
// below this — a window too short for it clips the bands beneath instead, so the waveform
|
||||
// stays a usable two-lane surface at every size.
|
||||
inline constexpr int kLaneMinHeight = 74;
|
||||
inline constexpr int kLaneGap = 2;
|
||||
inline constexpr int kWaveformMinHeight = 2 * kLaneMinHeight + kLaneGap;
|
||||
|
||||
// Vertical seam between adjacent bands.
|
||||
inline constexpr int kBandGap = 4;
|
||||
|
||||
// The vertical inventory. Bands never overlap and are returned top-to-bottom; a band may be
|
||||
// empty() on a degenerate window, in which case its owner draws and hit-tests nothing.
|
||||
struct SampleBands {
|
||||
Rect chrome; // full width: toolbar row + control row
|
||||
Rect waveform; // kPad-inset, elastic, >= kWaveformMinHeight
|
||||
Rect decks; // kPad-inset, bottom-anchored, height `deckHeight`
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the three bands. `deckHeight` is the knob deck's own
|
||||
// wrapped height (from knob_deck) — the only interior measurement the allocator needs, so
|
||||
// the deck band is exactly as tall as its content. Pure.
|
||||
SampleBands computeSampleBands(int w, int h, int deckHeight);
|
||||
|
||||
// The waveform band's two channel lanes: L above R, separated by kLaneGap. In mono only
|
||||
// `upper` is populated (it takes the whole band) and `lower` is empty — a mono capture has
|
||||
// no second lane to draw, and overlays that ride the waveform draw ONCE across the whole
|
||||
// band in either mode, never per lane.
|
||||
struct WaveformLanes {
|
||||
Rect upper;
|
||||
Rect lower; // empty() in mono
|
||||
};
|
||||
WaveformLanes waveformLanes(const Rect& waveform, bool stereo);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,71 @@
|
||||
// sample_chrome.cpp — see sample_chrome.h. Pure math; no host types.
|
||||
|
||||
#include "core/instrument/ui/sample_chrome.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight / kChromeRowHeight
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kStripBandHeight = 40; // the root/piano strip's own height inside the row
|
||||
|
||||
// The control row's fixed right-anchored run, right to left.
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
|
||||
} // namespace
|
||||
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize) {
|
||||
ChromeRects r;
|
||||
if (chrome.empty()) return r;
|
||||
|
||||
const int titleH = std::min(kTitleHeight, chrome.height);
|
||||
r.toolbar = Rect::ltrb(chrome.x, chrome.y, chrome.right(), chrome.y + titleH);
|
||||
r.controls = Rect::ltrb(chrome.x, r.toolbar.bottom(), chrome.right(), chrome.bottom());
|
||||
|
||||
const int navTop = r.toolbar.y + 2;
|
||||
const int navBot = std::max(navTop, r.toolbar.bottom() - 2);
|
||||
r.navBrowse = Rect::ltrb(std::max(chrome.x, chrome.right() - kPad - kNavButtonWidth),
|
||||
navTop, std::max(chrome.x, chrome.right() - kPad), navBot);
|
||||
|
||||
if (r.controls.empty()) return r;
|
||||
const Rect& row = r.controls;
|
||||
|
||||
// Vertically centre the two heights the row uses: the tall strip band (which the preview
|
||||
// button and velocity cell align to) and the smaller square/segment controls.
|
||||
const int stripTop = row.y + (row.height - kStripBandHeight) / 2;
|
||||
const int stripBot = stripTop + kStripBandHeight;
|
||||
|
||||
const int chanTop = row.y + (row.height - kChanSegH) / 2;
|
||||
const int chanRight = row.right() - kPad;
|
||||
r.chanStereo = Rect::ltrb(chanRight - kChanSegW, chanTop, chanRight, chanTop + kChanSegH);
|
||||
r.chanMono = Rect::ltrb(r.chanStereo.x - kChanSegW, chanTop, r.chanStereo.x,
|
||||
chanTop + kChanSegH);
|
||||
|
||||
const int curveTop = row.y + (row.height - kCurveBtnSize) / 2;
|
||||
r.curveBtn = Rect::ltrb(r.chanMono.x - kPad - kCurveBtnSize, curveTop,
|
||||
r.chanMono.x - kPad, curveTop + kCurveBtnSize);
|
||||
|
||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
||||
r.curveBtn.x - kPad, stripBot);
|
||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
||||
r.velCell.y + knobSize);
|
||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(),
|
||||
r.velCell.bottom());
|
||||
|
||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
||||
r.velCell.x - kPad, stripBot);
|
||||
// Remainder width; clamped so a narrow window collapses the strip rather than inverting it.
|
||||
r.rootStrip = Rect::ltrb(row.x + kPad, stripTop,
|
||||
std::max(row.x + kPad, r.preview.x - kPad), stripBot);
|
||||
return r;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
// sample_chrome.h — interior geometry of the Sample face's CHROME band: the toolbar row
|
||||
// (title + Browse) over the control row (root/piano strip, preview trigger, preview-velocity
|
||||
// knob cell, curve-preview button, Mono|Stereo toggle). Reads the band rect the allocator
|
||||
// hands it (sample_bands) and never allocates vertical space of its own.
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
inline constexpr int kNavButtonWidth = 62; // the Browse toolbar button
|
||||
|
||||
// Every interactive rect inside the chrome band, in one pass so draw and hit-test cannot
|
||||
// derive them differently. The control row's right-anchored run is fixed-width (preview,
|
||||
// velocity cell, curve button, channel toggle) and the root strip takes the remainder, so
|
||||
// the strip grows with the window.
|
||||
struct ChromeRects {
|
||||
Rect toolbar; // full-width top row
|
||||
Rect navBrowse; // right-anchored in the toolbar
|
||||
Rect controls; // full-width second row
|
||||
Rect rootStrip; // remainder-width, left
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the velocity-curve popup
|
||||
Rect chanMono;
|
||||
Rect chanStereo;
|
||||
};
|
||||
|
||||
// `knobSize` is the deck knob square, passed in so this module does not depend on knob_deck.
|
||||
ChromeRects chromeRects(const Rect& chrome, int knobSize);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
@@ -8,8 +8,8 @@ two small identity/helper headers this directory owns outright
|
||||
(`reasampler_vst.h`, `editor_internal.h`).
|
||||
|
||||
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
||||
`sample_map`, `component_state_io`, `zone_params.h`, `editor_geometry`,
|
||||
`keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `note_entry`,
|
||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||
@@ -78,7 +78,7 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
- No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not
|
||||
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||||
- The pure core stays REAPER-free *and* VST3-free — the voice engine / envelope /
|
||||
keymap / repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
marshals. Any VST3 or REAPER type leaking into `core/instrument` is a bug.
|
||||
- Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
|
||||
headers before use.
|
||||
@@ -86,17 +86,17 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
## Modules
|
||||
|
||||
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family (Q-W2v split), included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the former god-TU's anonymous-namespace helpers that more than one split TU needs. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/editor_internal.h`'s own header comment and body.)*
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs.
|
||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `editor_internal.h` is include-only — it has no TU of its own and must never become
|
||||
a public seam; only the eight `reasampler_editor` face-axis TUs include it.
|
||||
a public seam; only the `reasampler_editor` band-axis TUs include it.
|
||||
- The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
|
||||
`reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID.
|
||||
- The UID selection `#ifdef` in `reasampler_vst.h` is the one deliberate exception to
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
|
||||
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
|
||||
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
|
||||
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// Value logic only — no painting, no window plumbing.
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
|
||||
// applyControl — seconds/fraction/frames <-> normalized 0..1), the knob-deck group
|
||||
// descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam
|
||||
// converter). Value logic only — no painting, no window plumbing.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -14,14 +14,20 @@
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||
#include "core/util/clamp01.h"
|
||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
|
||||
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum
|
||||
using instrument::ui::computeSampleBands;
|
||||
using instrument::ui::chromeRects;
|
||||
using instrument::ui::deckHeight;
|
||||
using instrument::ui::kDeckKnobSize;
|
||||
using instrument::ui::kPad;
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
@@ -30,7 +36,7 @@ using util::clamp01;
|
||||
namespace {
|
||||
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
|
||||
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
|
||||
// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames
|
||||
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
|
||||
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
|
||||
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
|
||||
@@ -42,7 +48,18 @@ constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling
|
||||
|
||||
} // namespace
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
||||
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
|
||||
// chrome interior, and the deck descriptors can never be derived three different ways.
|
||||
// The deck's own wrapped height is the only interior measurement the allocator needs.
|
||||
FaceLayout fl;
|
||||
fl.deckDescs = deckGroupDescs(params_.play);
|
||||
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
|
||||
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
||||
return fl;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
|
||||
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
|
||||
@@ -74,7 +91,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
|
||||
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||
@@ -134,12 +151,10 @@ double ReaSamplerEditor::previewVelocity01() const {
|
||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
|
||||
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
|
||||
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
|
||||
// neighbouring groups.
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
|
||||
// The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
|
||||
// reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
|
||||
// flip repopulates in place and never reflows the neighbouring groups.
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
@@ -179,14 +194,6 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
|
||||
// appended here, not in zoneDeckGroupDescs.
|
||||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
@@ -206,22 +213,22 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySecond
|
||||
return out;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
|
||||
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
|
||||
double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(zone.keyTrack / kKeyTrackMax);
|
||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
case ParamControl::kMasterGain:
|
||||
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
||||
default:
|
||||
return controlValue(id, zone.play);
|
||||
return controlValue(id, params_.play);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
||||
if (!processor_) return;
|
||||
norm = clamp01(norm);
|
||||
if (id == -2) {
|
||||
@@ -247,15 +254,15 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
|
||||
return;
|
||||
default:
|
||||
applyZoneControl(zoneIndex, id, norm, 0);
|
||||
applyParamControl(id, norm, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||
char buf[24];
|
||||
buf[0] = '\0';
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const PlaySeconds& play = params_.play;
|
||||
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kAttack:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
|
||||
@@ -282,13 +289,13 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
|
||||
case ParamControl::kKeyTrack:
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
|
||||
case ParamControl::kVoiceCount:
|
||||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
||||
case ParamControl::kMasterGain:
|
||||
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
|
||||
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
||||
default:
|
||||
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
|
||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
||||
break;
|
||||
}
|
||||
return std::string(buf);
|
||||
@@ -309,7 +316,7 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
return b;
|
||||
}
|
||||
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const {
|
||||
AmpEnvelope env;
|
||||
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
||||
@@ -320,7 +327,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
env.sustainLevel = play.adsr.sustainLevel;
|
||||
env.releaseSeconds = play.adsr.releaseSeconds;
|
||||
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the zone's effective start
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the effective start
|
||||
// point so the fraction denominator matches the voice's actual post-start span. A zero play
|
||||
// length yields 0 fractions.
|
||||
env.lengthFraction = play.trigger.lengthFraction;
|
||||
@@ -332,7 +339,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
|
||||
std::int64_t startFrame, ZonePlaySeconds& play) const {
|
||||
std::int64_t startFrame, PlaySeconds& play) const {
|
||||
if (env.mode == EnvMode::Gate) {
|
||||
play.adsr.attackSeconds = env.attackSeconds;
|
||||
play.adsr.holdSeconds = env.holdSeconds;
|
||||
@@ -342,7 +349,7 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
} else {
|
||||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
||||
// the played span (the trigger-seam converter, unpack direction). startFrame is the
|
||||
// zone's effective start point so the frame denominator matches the voice's actual
|
||||
// effective start point so the frame denominator matches the voice's actual
|
||||
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
|
||||
// so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||||
@@ -353,39 +360,13 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
}
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||||
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
|
||||
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
}
|
||||
return effectiveSampleZone();
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::popupZoneIndex() {
|
||||
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
|
||||
// Zone surface never materializes (the button only shows for an explicit selection); the
|
||||
// Sample face finds-or-materializes the picked id's one-zone site.
|
||||
if (view_ == View::kZone) {
|
||||
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
|
||||
? selectedZone_
|
||||
: -1;
|
||||
}
|
||||
return ensureSampleZone();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1.
|
||||
z.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||
params_.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
} else {
|
||||
applyControl(id, z.play, value, segment);
|
||||
applyControl(id, params_.play, value, segment);
|
||||
}
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -115,7 +115,7 @@ inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
|
||||
instrument::ui::cardThumbnailRect(layout, 0))));
|
||||
}
|
||||
|
||||
// Draws the title band with the live readout. Browse/Zone draw their own back button in
|
||||
// Draws the title band with the live readout. The Browse modal draws its own back button in
|
||||
// place of the nav.
|
||||
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
const std::string& readout) {
|
||||
@@ -172,7 +172,7 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
|
||||
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
|
||||
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
|
||||
// a keyboard at a glance. Shared by the setup face + the Zones strip.
|
||||
// a keyboard at a glance.
|
||||
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
|
||||
using instrument::ui::StripLayout;
|
||||
if (stripArea.width <= 0 || stripArea.height <= 0) return;
|
||||
|
||||
@@ -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
|
||||
+7
-124
@@ -1,9 +1,7 @@
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
|
||||
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
|
||||
// Shares the Sample face's painters (title band / empty state / deck / curve button /
|
||||
// popup) via the class + editor_internal.h.
|
||||
// editor_paint_browse.cpp — the Browse modal's painter: the full-window
|
||||
// select-then-confirm picker (wash, search box, filter tabs, card grid, scrollbar, footer).
|
||||
// Windows-only. Shares the Sample face's title-band + empty-state painters via the class +
|
||||
// editor_internal.h.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,15 +13,14 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + labels
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
|
||||
using namespace reasampler::instrument::ui; // browser geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice
|
||||
|
||||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
|
||||
@@ -157,120 +154,6 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
|
||||
// to the Sample home on close.
|
||||
const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h));
|
||||
drawTitleBand(bmp, title, "Zone - keyboard map");
|
||||
{
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
const KitButtonBox box{toKitBox(back)};
|
||||
const InteractionState st =
|
||||
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "Back", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
const Rect content = zoneContentArea(w, h);
|
||||
|
||||
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
|
||||
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
|
||||
Rect addR = zoneAddRect(content);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(addR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0) {
|
||||
const KitButtonBox box{toKitBox(delR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
|
||||
// read-only here), so it is a normal button, not `warn`.
|
||||
drawButton(bmp, box, "Delete", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
|
||||
// zone is live"); the rest take the categorical secondary hue at low alpha.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
drawSpectralStrip(bmp, stripArea);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int sx = stripArea.x;
|
||||
const int sy = stripArea.y;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
|
||||
const int bw = (std::max)(2, bar.width);
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo behind the live zone, then the crisp accent-primary bar.
|
||||
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
|
||||
// text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom() + 8;
|
||||
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
|
||||
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
|
||||
z.sampleId)
|
||||
.c_str(),
|
||||
Font::Label, Role::TextPrimary);
|
||||
// Three fields laid out left-to-right after the sample label. A focused field lifts to
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
const char* names[3] = {"Low", "High", "Root"};
|
||||
const std::string vals[3] = {
|
||||
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
const Rect fr = noteEntryFieldRect(fields, f);
|
||||
const bool editing = (entryField_ == f);
|
||||
fillSurface(bmp, toKitBox(fr), Role::BgCell,
|
||||
editing ? InteractionState::Focus : InteractionState::Rest);
|
||||
const KitColor border =
|
||||
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
|
||||
toLice(border), 1.0f, 0);
|
||||
std::string cap = std::string(names[f]) + ": " +
|
||||
(editing ? (entryText_ + "_") : vals[f]);
|
||||
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
|
||||
Font::ValueMono, Role::TextPrimary);
|
||||
}
|
||||
} else if (map_.zones.empty()) {
|
||||
kitText(bmp, infoR,
|
||||
"No zones. Add Zone maps the picked capture across the keyboard.",
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone: the same knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the
|
||||
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
|
||||
// (ComponentState) and live on the Sample deck only.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
|
||||
// modal grammar as the Sample face.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,118 @@
|
||||
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
||||
// live readout + Browse) and the control row (root/piano strip with its root marker, the
|
||||
// preview trigger, the preview-velocity knob cell, the curve-preview button, and the
|
||||
// Mono|Stereo toggle). Windows-only; all rects come from the pure sample_chrome interior.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face / spectral strip / root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // chrome geometry + keyboard strip
|
||||
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
|
||||
|
||||
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
|
||||
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
drawTitleBand(bmp, cr.toolbar, title);
|
||||
|
||||
// Browse: the picker. When nothing is loaded it is the empty state's dominant
|
||||
// call-to-action — draw it Active (accent-primary) so it reads as "start here".
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.navBrowse)};
|
||||
const InteractionState st = empty ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The control row draws only once a capture is loaded — with nothing picked there is no
|
||||
// root, no preview and no channel decision to make.
|
||||
if (empty || cr.controls.empty()) return;
|
||||
|
||||
fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest);
|
||||
|
||||
// Root strip: the full 128-key spectral band with the root marked. The loaded capture
|
||||
// responds across the whole strip, repitched from that root.
|
||||
if (cr.rootStrip.width > 0) {
|
||||
drawSpectralStrip(bmp, cr.rootStrip);
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
drawRootMarker(bmp, cr.rootStrip, sl, effectiveRoot());
|
||||
}
|
||||
|
||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.preview)};
|
||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||||
const InteractionState st = dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||||
if (dragging || hov) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d",
|
||||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
|
||||
// The mini curve-preview button: opens the popup editor.
|
||||
paintCurveButton(bmp, cr.curveBtn);
|
||||
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, cr.chanMono, "Mono", Font::Label,
|
||||
!isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, cr.chanStereo, "Stereo", Font::Label,
|
||||
isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -219,20 +219,18 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
if (self->drag_ != DragKind::kNone) {
|
||||
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
|
||||
// voice count / master gain) are transient (no map mutation; dragStartMap_
|
||||
// not snapshotted) — reset drag state only, never touch map_. Every
|
||||
// map-editing drag rolls its live mutation back to the snapshot.
|
||||
// voice count / master gain) are transient (they mutate no parameter, so
|
||||
// dragStartParams_ is not a rollback target) — reset drag state only.
|
||||
// Every parameter-editing drag rolls its live mutation back to the snapshot.
|
||||
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
||||
(self->drag_ == DragKind::kDeckKnob &&
|
||||
(self->dragParamId_ == -2 ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
|
||||
if (!transient) self->map_ = self->dragStartMap_;
|
||||
if (!transient) self->params_ = self->dragStartParams_;
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->dragParamId_ = -1;
|
||||
self->dragParamZone_ = -1;
|
||||
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
|
||||
self->dragCurveZone_ = -1;
|
||||
self->invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
|
||||
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert
|
||||
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits
|
||||
// off the audio thread via the processor's reloadInstrument.
|
||||
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
|
||||
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
|
||||
// thread via the processor's reloadInstrument.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -38,8 +38,8 @@ using util::readFileBytes;
|
||||
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
: CPluginView(nullptr), processor_(processor) {
|
||||
// Default view size, tuned to the Sample-face band heights: title + hero waveform +
|
||||
// cluster + control strip. 840x620 clears the full face without scroll on 1080p.
|
||||
// Default view size, tuned to the three band heights: chrome + two-lane waveform +
|
||||
// deck row. 840x620 clears the full face without scroll on 1080p.
|
||||
ViewRect r(0, 0, 840, 620);
|
||||
setRect(r);
|
||||
}
|
||||
@@ -53,30 +53,21 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
banks_.clear();
|
||||
visible_.clear();
|
||||
selectedId_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
params_ = InstrumentParams{};
|
||||
return;
|
||||
}
|
||||
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
|
||||
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
const auto prevZoneCount = static_cast<int>(map_.zones.size());
|
||||
map_ = processor_->performanceMap();
|
||||
params_ = processor_->instrumentParams();
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
monoTrigger_ = processor_->monoTrigger();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
||||
// modal would otherwise swallow clicks on the empty state.
|
||||
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
|
||||
// On the Zone surface, close the popup if the zone count changed at all — a mid-list
|
||||
// deletion can leave selectedZone_ in range but silently naming a different zone.
|
||||
if (view_ == View::kZone && curvePopupOpen_) {
|
||||
const auto newZoneCount = static_cast<int>(map_.zones.size());
|
||||
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
|
||||
}
|
||||
if (selectedId_.empty()) curvePopupOpen_ = false;
|
||||
// Drop a filter that names a bank no longer present.
|
||||
if (!activeFilterBankId_.empty()) {
|
||||
bool found = false;
|
||||
@@ -127,13 +118,13 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio
|
||||
// thread. The reload also copies the picked capture's file ref + intrinsics into the
|
||||
// instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// UI thread only. Publishes the edited selection + parameter set, then rebuilds off the
|
||||
// audio thread. The reload also copies the loaded capture's file ref + intrinsics into
|
||||
// the instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// self-contained for that sample.
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setPerformanceMap(map_);
|
||||
processor_->setInstrumentParams(params_);
|
||||
processor_->reloadInstrument();
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
@@ -144,23 +135,25 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized
|
||||
// full-range zone must not linger, or first-match resolve would keep playing it.
|
||||
// Authored Zone-view maps (narrow key ranges) are left untouched.
|
||||
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
|
||||
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
|
||||
// loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED
|
||||
// overrides are: a root, a loop span and a start frame all name positions in the
|
||||
// OUTGOING capture and mean nothing in the new one, so they clear and the new capture
|
||||
// plays from its own bank intrinsics.
|
||||
selectedId_ = id;
|
||||
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||||
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||||
}
|
||||
params_.rootOverride.reset();
|
||||
params_.loopOverride.reset();
|
||||
params_.startPoint.reset();
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
||||
SetupMarkers m;
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from
|
||||
// the live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override
|
||||
// lives in map_.
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
|
||||
// override win (the instrument's performance choice). Read the loop intrinsic from the
|
||||
// live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics.
|
||||
if (processor_) {
|
||||
std::optional<SelectedSample> sel;
|
||||
auto banksJson =
|
||||
@@ -176,17 +169,13 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
m.loopEnd = sel->loop.end;
|
||||
}
|
||||
}
|
||||
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId != selectedId_) continue;
|
||||
if (z.loopOverride) {
|
||||
m.hasLoop = z.loopOverride->hasLoop;
|
||||
m.loopStart = z.loopOverride->start;
|
||||
m.loopEnd = z.loopOverride->end;
|
||||
}
|
||||
if (z.startPoint) m.start = *z.startPoint;
|
||||
break;
|
||||
// The parameter set's override (loop + start) supersedes the intrinsic.
|
||||
if (params_.loopOverride) {
|
||||
m.hasLoop = params_.loopOverride->hasLoop;
|
||||
m.loopStart = params_.loopOverride->start;
|
||||
m.loopEnd = params_.loopOverride->end;
|
||||
}
|
||||
if (params_.startPoint) m.start = *params_.startPoint;
|
||||
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
|
||||
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
|
||||
// the markers are still drawn (drag one to CREATE a loop).
|
||||
@@ -194,79 +183,23 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
return m;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The
|
||||
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be
|
||||
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
|
||||
// callers can update selectedZone_.
|
||||
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
|
||||
// Write the edited markers into the parameter set as the loop/start override. The bank
|
||||
// intrinsic is never written (read-only bank consumer).
|
||||
SampleLoop loop;
|
||||
loop.hasLoop = m.hasLoop;
|
||||
loop.start = m.loopStart;
|
||||
loop.end = m.loopEnd;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == selectedId_) {
|
||||
z.loopOverride = loop;
|
||||
z.startPoint = m.start;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.loopOverride = loop;
|
||||
z.startPoint = m.start;
|
||||
map_.zones.push_back(z);
|
||||
return static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
|
||||
// The picked id's one-zone override, if the map already carries one; else a product-default
|
||||
// zone bound to the picked id (not appended — a read-only resolve; a control edit
|
||||
// materializes it via ensureSampleZone).
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_) return z;
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
return z;
|
||||
params_.loopOverride = loop;
|
||||
params_.startPoint = m.start;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::effectiveRoot() const {
|
||||
int root = 60;
|
||||
if (params_.rootOverride) return *params_.rootOverride;
|
||||
for (const SampleChoice& s : samples_) {
|
||||
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
|
||||
if (s.id == selectedId_ && s.rootNote) return *s.rootNote;
|
||||
}
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::ensureSampleZone() {
|
||||
if (selectedId_.empty()) return -1;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map_.zones.push_back(z);
|
||||
return static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert):
|
||||
// a full-keyboard zone carrying the override. This plays identically to the un-zoned single
|
||||
// capture (one chromatic zone) and round-trips through the component state; the zone becomes
|
||||
// visible if the user opens the Zones panel. The bank intrinsic is never written.
|
||||
if (selectedId_.empty()) return;
|
||||
upsertPickedOverride(m);
|
||||
commitAndReload();
|
||||
return 60;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the
|
||||
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
|
||||
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
|
||||
// usage publish. Nothing here runs on the audio thread — process() only touches the
|
||||
@@ -19,7 +19,7 @@
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained)
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
|
||||
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
|
||||
@@ -60,11 +60,10 @@ std::string mintUsageInstanceGuid() {
|
||||
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
|
||||
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
|
||||
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
|
||||
// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the
|
||||
// zoned build and the single-capture path.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
// resolve/read/decode failure — the caller plays silence.
|
||||
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
if (abs.empty()) return std::nullopt;
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
@@ -72,8 +71,8 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
if (out.monoFrames.empty()) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
@@ -95,8 +94,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// nothing below — a project restored before PROJEXTSTATE parses (or with the
|
||||
// extension absent) resolves + plays from the persisted refs.
|
||||
const std::string selId = selectedSampleId();
|
||||
const PerformanceMap map = performanceMap();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId, map);
|
||||
const InstrumentParams params = instrumentParams();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId);
|
||||
SampleRefs refs;
|
||||
{
|
||||
std::optional<std::string> banksJson =
|
||||
@@ -110,8 +109,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch
|
||||
// below may auto-default it before its decode.
|
||||
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
|
||||
// capture's own channel count below, before the decode.
|
||||
ChannelMode mode = channelMode();
|
||||
// Snapshot the voice-system parameters once — baked into the built engine's
|
||||
// construction (immutable config; a later change rebuilds).
|
||||
@@ -127,60 +126,33 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
SampleData sample;
|
||||
bool havePlayable = false;
|
||||
|
||||
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the
|
||||
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread,
|
||||
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole
|
||||
// map — the defined no-play, no crash, no retry loop.
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
std::vector<ResolvedZone> kept;
|
||||
decoded.reserve(resolved.zones.size());
|
||||
kept.reserve(resolved.zones.size());
|
||||
for (const ResolvedZone& rz : resolved.zones) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, rz.relativePath, mode);
|
||||
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
|
||||
kept.push_back(rz);
|
||||
decoded.push_back(std::move(*pcm));
|
||||
}
|
||||
km = buildZonedKeymap(kept, decoded);
|
||||
haveKeymap = !km.zones.empty();
|
||||
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
|
||||
// repitched from its effective root. No first-sample fallback: an empty selection
|
||||
// (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
|
||||
// rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
|
||||
// no-play — no crash, no retry loop.
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
||||
// count (always 2 for extension captures; mono only for ingest-imported mono files).
|
||||
// An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
|
||||
havePlayable = sample.playable();
|
||||
if (havePlayable) resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path: an empty performance map plays the one selected capture
|
||||
// chromatically across the whole keyboard. No first-sample fallback: an empty
|
||||
// selection (or one with no ref) resolves to nothing, so an un-picked instrument
|
||||
// stays silent rather than auto-playing sample #1.
|
||||
if (!haveKeymap) {
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// channel count (always 2 for extension captures; mono only for ingest-imported
|
||||
// mono files). An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||
sel->rootNote, sel->loop,
|
||||
std::move(pcm->framesR));
|
||||
haveKeymap = true;
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
if (havePlayable) {
|
||||
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
|
||||
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
|
||||
// valid window is always a real ring, covering a pathological host rate <= 0 too.
|
||||
@@ -188,16 +160,16 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
}
|
||||
|
||||
// 4. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// header). A null `built` (no ref / unreadable WAV) installs silence while any
|
||||
// displaced tails still ring out via the drain.
|
||||
publishBuiltLocked(std::move(built));
|
||||
|
||||
// 5. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// 4. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// them. Regardless of decode success: the holds are the refs the instance retains
|
||||
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
|
||||
publishUsage(refs, ids);
|
||||
@@ -260,7 +232,7 @@ void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr<LoadedInstrument> b
|
||||
|
||||
void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
// Off the audio thread. A voice-param change touches no audio data, so this rebuilds
|
||||
// the engine around a copy of the live instrument's already-decoded keymap — no
|
||||
// the engine around a copy of the live instrument's already-decoded SampleData — no
|
||||
// bridge, no disk — and publishes through the same drain-slot swap.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
LoadedInstrument* cur = live_.load(std::memory_order_acquire);
|
||||
@@ -282,12 +254,12 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
|
||||
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because
|
||||
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from
|
||||
// being freed.
|
||||
Keymap km = cur->keymap;
|
||||
// Deep-copy the decoded sample: safe to read concurrently with process() because the
|
||||
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
|
||||
// freed.
|
||||
SampleData sample = cur->sample;
|
||||
auto built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
publishBuiltLocked(std::move(built));
|
||||
}
|
||||
@@ -322,7 +294,7 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
||||
const LegacyLiftDecision decision = legacyLiftDecision(
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
||||
referencedSampleIds(selectedSampleId(), performanceMap()));
|
||||
referencedSampleIds(selectedSampleId()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale: give up permanently. A later bank change that re-introduces an
|
||||
// id bumps the generation, and genChanged refreshes the refs without this latch.
|
||||
@@ -379,15 +351,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
|
||||
if (decision.apply) {
|
||||
// Apply as this instance's own selection (the instrument updates its own state,
|
||||
// never the bank); reloadInstrument below rebuilds against it.
|
||||
// never the bank); reloadInstrument below rebuilds against it. The parameter set
|
||||
// carries over to the new capture — there is only one, and it governs whatever is
|
||||
// loaded (the peer of the editor's Browse Load).
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
// Peer of the editor's Browse Load: a stale full-range zone from the previous
|
||||
// sample would shadow the assigned pick under first-match resolve. Authored maps
|
||||
// (narrow key ranges) are untouched.
|
||||
PerformanceMap reconciled = performanceMap();
|
||||
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||
setPerformanceMap(reconciled);
|
||||
}
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
@@ -412,8 +379,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// dependency (a v10 blob plays from its refs with no poll at all).
|
||||
bool legacyLift = false;
|
||||
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
legacyLift = hasIntent && legacyLiftShouldRun();
|
||||
legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
|
||||
}
|
||||
|
||||
if (genChanged || result.applied || legacyLift) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
|
||||
// against the component_state_io codec) and its UI-thread parameter accessors/setters
|
||||
// (selection, performance map, channel mode, preview velocity, voice-system params,
|
||||
// (selection, the one parameter set, channel mode, preview velocity, voice-system params,
|
||||
// master gain, preview-note mailbox posts). Everything here runs off the audio thread;
|
||||
// setters hand work to the reload family (processor_reload.cpp) or store atomics
|
||||
// process() picks up at block start.
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
|
||||
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
|
||||
#include "core/instrument/map/sample_map.h" // retainRefs / referencedSampleIds
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
@@ -34,18 +34,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
// Component state is {single-capture selection id, opt-in zones}, restored explicitly
|
||||
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState
|
||||
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_
|
||||
// is the real host rate here — REAPER calls setupProcessing before setState on load.
|
||||
// Component state is {loaded capture id, one parameter set}. deserializeComponentState
|
||||
// lifts older blobs cleanly — including the retired zone payloads, which adopt zone
|
||||
// one's capture into cs.selectionId. sampleRate_ is the real host rate here (REAPER
|
||||
// calls setupProcessing before setState on load), which the legacy v3 payload's
|
||||
// frames->seconds conversion needs.
|
||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones
|
||||
// (one per sample ever browsed), the oldest shadowing the saved selection under
|
||||
// first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched.
|
||||
PerformanceMap restored = cs.map;
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally
|
||||
setPerformanceMap(restored);
|
||||
setInstrumentParams(cs.params);
|
||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request.
|
||||
{
|
||||
@@ -95,11 +91,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick + no zones serializes to {"", no zones}, restoring as silence (never
|
||||
// auto-playing sample #1).
|
||||
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
||||
// sample #1).
|
||||
ComponentState state_out;
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
state_out.params = instrumentParams();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
state_out.channelMode = channelMode_;
|
||||
@@ -121,8 +117,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
// present. Filtered (snapshot copy only) to what the instance currently plays, so the
|
||||
// table cannot grow with browsing history.
|
||||
state_out.sampleRefs = sampleRefs();
|
||||
retainRefs(state_out.sampleRefs,
|
||||
referencedSampleIds(state_out.selectionId, state_out.map));
|
||||
retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId));
|
||||
// Persist the publish identity so the usage key is stable across sessions.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
@@ -147,14 +142,14 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
PerformanceMap ReaSamplerProcessor::performanceMap() {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
return performanceMap_;
|
||||
InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
return params_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
performanceMap_ = map;
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
params_ = params;
|
||||
}
|
||||
|
||||
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then
|
||||
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in
|
||||
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
|
||||
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
|
||||
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
|
||||
// pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -16,9 +17,11 @@
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
|
||||
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
|
||||
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
|
||||
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -32,25 +35,26 @@ namespace reasampler::vst {
|
||||
using audio::AudioSample;
|
||||
using audio::Envelope;
|
||||
using instrument::map::BankChoice;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::PerformanceZone;
|
||||
using instrument::map::InstrumentParams;
|
||||
using instrument::map::PlaySeconds;
|
||||
using instrument::map::SampleChoice;
|
||||
using instrument::map::SampleRefEntry;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::ZonePlaySeconds;
|
||||
using instrument::ui::AmpEnvelope;
|
||||
using instrument::ui::ChromeRects;
|
||||
using instrument::ui::DeckGroupDesc;
|
||||
using instrument::ui::EnvClampBounds;
|
||||
using instrument::ui::EnvNode;
|
||||
using instrument::ui::Rect;
|
||||
using instrument::ui::SampleBands;
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `processor` outlives this editor; the editor reads the live bank through it and drives
|
||||
// selection/zone edits + reload on user input. May be null (defensive; a real host always
|
||||
// supplies one).
|
||||
// selection/parameter edits + reload on user input. May be null (defensive; a real host
|
||||
// always supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
@@ -65,18 +69,18 @@ protected:
|
||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||
|
||||
private:
|
||||
// Sample is the home/default face. Browse is a full-window modal picker overlaid on
|
||||
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned.
|
||||
enum class View { kSample, kBrowse, kZone };
|
||||
// Sample is the home/default face (the three-band stack). Browse is a full-window modal
|
||||
// picker overlaid on it.
|
||||
enum class View { kSample, kBrowse };
|
||||
|
||||
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
|
||||
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
||||
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
|
||||
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
|
||||
kCurveNode, kDeckKnob };
|
||||
|
||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||
// knob_deck hit-test returns; the shell maps it to the zone's play params or a
|
||||
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
||||
// processor-side per-instance setter.
|
||||
enum class ParamControl {
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
@@ -93,9 +97,9 @@ private:
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to
|
||||
// the processor setters, never through applyZoneControl / the map.
|
||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||
// setters, never through applyParamControl.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
@@ -103,8 +107,8 @@ private:
|
||||
kCount
|
||||
};
|
||||
|
||||
// The waveform markers on the single-capture setup surface: start-point + the sustain
|
||||
// loop's two ends, in draw + hit order.
|
||||
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
||||
// in draw + hit order.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
|
||||
@@ -112,9 +116,8 @@ private:
|
||||
// not applicable.
|
||||
enum class HoverKind {
|
||||
kNone,
|
||||
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
|
||||
kNavZone, // the Sample-view "Zone" title-band button (opens the Zone surface)
|
||||
kBack, // the Browse/Zone "back" affordance (returns to Sample)
|
||||
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
|
||||
kBack, // the Browse "back" affordance (returns to Sample)
|
||||
kSearchBox, // the browser search box
|
||||
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
|
||||
kCard, // a capture card (index = visible_ index)
|
||||
@@ -122,13 +125,11 @@ private:
|
||||
kBrowseCancel, // the Browse modal "Cancel" button
|
||||
kChanMono, // the mono channel-mode segment
|
||||
kChanStereo, // the stereo channel-mode segment
|
||||
kPreview, // the Sample-view preview-trigger button
|
||||
kAddZone, // the "+ Add Zone" button
|
||||
kDeleteZone, // the "Delete" zone button
|
||||
kPreview, // the preview-trigger button
|
||||
kControl, // a knob-deck element (index = control id)
|
||||
kCurveNode, // a velocity-curve control point (index = point index)
|
||||
kVelKnob, // the cluster preview-velocity radial knob
|
||||
kCurveButton, // the cluster mini curve-preview button (opens the popup)
|
||||
kVelKnob, // the chrome preview-velocity radial knob
|
||||
kCurveButton, // the chrome mini curve-preview button (opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button
|
||||
};
|
||||
struct HoverTarget {
|
||||
@@ -138,60 +139,82 @@ private:
|
||||
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// The three-band stack for the current client size, plus the chrome interior. Every
|
||||
// paint/hit-test path derives both through this one call so draw and hit-test can never
|
||||
// disagree about where a band is.
|
||||
struct FaceLayout {
|
||||
SampleBands bands;
|
||||
ChromeRects chrome;
|
||||
std::vector<DeckGroupDesc> deckDescs;
|
||||
};
|
||||
FaceLayout faceLayout(int w, int h) const;
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
|
||||
// The knob deck: group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs
|
||||
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top).
|
||||
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs);
|
||||
// The mini curve-preview button shared by the Sample cluster + the Zone panel.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site
|
||||
// or the Zone surface's selected zone.
|
||||
// --- Band painters (one TU each, mirroring the input side) ---
|
||||
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
|
||||
// curve button, channel toggle).
|
||||
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
|
||||
// Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
|
||||
void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
|
||||
// Decks: the group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag.
|
||||
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
||||
|
||||
// The mini curve-preview button (chrome) and the modal curve editor it summons.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r);
|
||||
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`.
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
|
||||
std::int64_t frames);
|
||||
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
|
||||
// host is the popup sheet. `r` empty -> draws nothing.
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
|
||||
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an
|
||||
// empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers
|
||||
// materialize first).
|
||||
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
|
||||
|
||||
// Left-click while the curve popup is open (modal over both faces): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
|
||||
// full band height (never per lane).
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, std::int64_t frames);
|
||||
|
||||
// --- Input: the mouse-down dispatch and its per-band branches ---
|
||||
void onMouseDown(int x, int y);
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in
|
||||
// editor_input_browse_zone.cpp).
|
||||
// Each returns true when it consumed the click. Called in band order by onMouseDown.
|
||||
bool mouseDownChrome(const FaceLayout& fl, int x, int y);
|
||||
bool mouseDownWaveform(const FaceLayout& fl, int x, int y);
|
||||
bool mouseDownDeck(const FaceLayout& fl, int x, int y);
|
||||
void mouseDownBrowse(int w, int h, int x, int y);
|
||||
void mouseDownZone(int w, int h, int x, int y);
|
||||
|
||||
// Live drag resolution, split on the same axis; each handles only its own DragKind
|
||||
// values and is called from onMouseMove's router.
|
||||
void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker
|
||||
void dragWaveform(const FaceLayout& fl, int x, int y); // kEnvNode / kWaveMarker
|
||||
void dragDeck(int x, int y); // kDeckKnob
|
||||
void dragBrowse(int x, int y); // kScrollThumb
|
||||
void dragCurve(int x, int y); // kCurveNode
|
||||
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
// Right-click is the curve popup's primary node-delete affordance; only acts while the
|
||||
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
|
||||
void onMouseRDown(int x, int y);
|
||||
|
||||
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar
|
||||
// (0..200% over the knob's 0..1).
|
||||
void applyZoneControl(int zoneIndex, int id, double value, int segment);
|
||||
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
|
||||
// Alt-click on an interior node deletes it at once; an empty-space click adds a point
|
||||
// and grabs it.
|
||||
void handleCurveMouseDown(const Rect& r, int x, int y);
|
||||
|
||||
// Left-click while the curve popup is open (modal over the Sample face): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
|
||||
// Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
|
||||
// Repaints only on change, so an idle move is free. Windows-only.
|
||||
// Repaints only on change, so an idle move is free. The per-band resolvers mirror the
|
||||
// mouse-down branches but are read-only. Windows-only.
|
||||
void resolveHover(int x, int y);
|
||||
HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
|
||||
HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
|
||||
HoverTarget hoverBrowse(int w, int h, int x, int y) const;
|
||||
HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
|
||||
bool isHovered(HoverKind kind, int index) const {
|
||||
return hover_.kind == kind && hover_.index == index;
|
||||
}
|
||||
@@ -215,16 +238,15 @@ private:
|
||||
#endif
|
||||
|
||||
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
||||
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||
// selection + parameter set. Main/UI thread only. Called on attach and after any edit.
|
||||
void refreshFromBank();
|
||||
|
||||
// Publishes the edited zones/selection to the processor, then rebuilds the instrument
|
||||
// off the audio thread. UI thread only.
|
||||
// Publishes the edited selection + parameters to the processor, then rebuilds the
|
||||
// instrument off the audio thread. UI thread only.
|
||||
void commitAndReload();
|
||||
|
||||
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones
|
||||
// first so the previous sample's materialized full-range zone cannot linger and shadow
|
||||
// the new pick under first-match resolve, then publishes + reloads.
|
||||
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
|
||||
// whatever is loaded, so a load swaps the sound, not the settings.
|
||||
void loadSelection(const std::string& id);
|
||||
|
||||
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
|
||||
@@ -240,8 +262,8 @@ private:
|
||||
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked capture: the per-zone override when
|
||||
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// The effective loop + start markers for the loaded capture: the parameter set's
|
||||
// override when one is set, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
@@ -251,94 +273,74 @@ private:
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commits an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id), then reloads off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
|
||||
// callers decide live-drag vs final commit.
|
||||
void applyMarkers(const SetupMarkers& m);
|
||||
|
||||
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_
|
||||
// must be non-empty. Returns the updated/appended zone index.
|
||||
int upsertPickedOverride(const SetupMarkers& m);
|
||||
|
||||
// Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch
|
||||
// envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames.
|
||||
// Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
|
||||
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
|
||||
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
|
||||
// centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
double controlValue(int id, const PlaySeconds& play) const;
|
||||
|
||||
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
|
||||
// toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
void applyControl(int id, PlaySeconds& play, double value, int segment) const;
|
||||
|
||||
// Applies a knob/toggle interaction to the ONE parameter set for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the keyTrack scalar (0..200%
|
||||
// over the knob's 0..1).
|
||||
void applyParamControl(int id, double value, int segment);
|
||||
|
||||
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
|
||||
// against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing.
|
||||
// against the live rate — never a baked-in rate. Returns 0 when the rate is unknown.
|
||||
double fadeMaxFrames() const;
|
||||
|
||||
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
|
||||
// while the zone stores source frames — pack/unpack own that conversion (see
|
||||
// while the parameter set stores source frames — pack/unpack own that conversion (see
|
||||
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
|
||||
// seconds are rate-free and copy 1-to-1.
|
||||
|
||||
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective
|
||||
// start point (zone.startPoint.value_or(0)).
|
||||
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
|
||||
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place.
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
|
||||
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
|
||||
ZonePlaySeconds& play) const;
|
||||
PlaySeconds& play) const;
|
||||
|
||||
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
||||
// never produce a param a slider couldn't.
|
||||
EnvClampBounds envClampBounds() const;
|
||||
|
||||
// The Sample face and the Zone surface read/write the same one-zone map site.
|
||||
// effectiveSampleZone returns the picked id's override if present in map_, else a
|
||||
// product-default zone (not yet materialized — a control edit does that).
|
||||
PerformanceZone effectiveSampleZone() const;
|
||||
// The effective root: rootOverride, else the bank intrinsic, else middle C.
|
||||
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
|
||||
double liveSampleRate() const;
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
|
||||
// Find-or-materializes the one-zone override for the picked id, appending a
|
||||
// product-default zone if none exists. Mirror of upsertPickedOverride for a control
|
||||
// edit. Returns -1 if selectedId_ is empty.
|
||||
int ensureSampleZone();
|
||||
// The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two
|
||||
// reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH
|
||||
// ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) /
|
||||
// MASTER (Gain knob).
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const PlaySeconds& play) const;
|
||||
|
||||
// The popup edits ONE zone per open: the Zone surface's selected zone or the Sample
|
||||
// face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit
|
||||
// target — materializes on the Sample face via ensureSampleZone, never on the Zone
|
||||
// surface (button only shows for an explicit selection). -1 = no valid target.
|
||||
PerformanceZone popupZone() const;
|
||||
int popupZoneIndex();
|
||||
|
||||
// The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger
|
||||
// Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows
|
||||
// neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob +
|
||||
// Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups.
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
|
||||
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||
// via the -2 sentinel) read the processor's live value.
|
||||
double deckControlNorm(int id, const PerformanceZone& zone) const;
|
||||
double deckControlNorm(int id) const;
|
||||
|
||||
// Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on
|
||||
// release); processor params write through the processor setters immediately
|
||||
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids.
|
||||
void applyDeckKnob(int zoneIndex, int id, double norm);
|
||||
// Applies a deck-knob value: parameter-set ids write params_ (commit on release);
|
||||
// processor params write through the processor setters immediately (transient — no
|
||||
// params edit, no reload).
|
||||
void applyDeckKnob(int id, double norm);
|
||||
|
||||
// The knob's live value label shown during hover/drag: seconds, percents, source
|
||||
// frames, signed semitones, a voice count, or the master-gain dB.
|
||||
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
|
||||
std::string deckValueLabel(int id) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
|
||||
@@ -346,8 +348,8 @@ private:
|
||||
std::vector<SampleChoice> samples_; // every bank sample, bank order
|
||||
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||
std::string selectedId_; // the loaded capture ("" = empty state)
|
||||
InstrumentParams params_; // the ONE parameter set governing it
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
@@ -357,10 +359,9 @@ private:
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// Transient UI state (not persisted; component state carries selection + zones).
|
||||
// Transient UI state (not persisted; component state carries selection + parameters).
|
||||
View view_ = View::kSample; // default face is the loaded-sample home
|
||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
|
||||
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
|
||||
// Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
|
||||
@@ -381,11 +382,6 @@ private:
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_
|
||||
// accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing.
|
||||
int entryField_ = -1;
|
||||
std::string entryText_;
|
||||
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
#ifdef _WIN32
|
||||
@@ -398,10 +394,8 @@ private:
|
||||
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||
int dragCurX_ = 0; // live cursor x (px) during a drag — updated in onMouseMove
|
||||
int dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove
|
||||
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
||||
int dragStartHigh_ = 0;
|
||||
int dragStartRoot_ = 60;
|
||||
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
|
||||
int dragStartRoot_ = 60; // the root note at grab time
|
||||
InstrumentParams dragStartParams_; // params_ snapshotted at grab; restored on capture-loss
|
||||
|
||||
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
|
||||
// pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
|
||||
@@ -409,12 +403,11 @@ private:
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
|
||||
std::int64_t dragStartFrame_ = 0; // effective start point at grab time; for env-node drag
|
||||
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone.
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
|
||||
int dragStartScrollOffset_ = 0;
|
||||
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
|
||||
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
|
||||
|
||||
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
|
||||
// contract, per envelope_edit's grabEnv).
|
||||
@@ -422,19 +415,16 @@ private:
|
||||
AmpEnvelope dragStartEnv_{};
|
||||
|
||||
// Velocity-curve node drag: which point, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone
|
||||
// place the editor differently), and which zone the edit lands on.
|
||||
// (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
|
||||
int curvePointIndex_ = -1;
|
||||
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
|
||||
Rect dragCurveRect_{};
|
||||
int dragCurveZone_ = -1;
|
||||
|
||||
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
|
||||
// delta from this anchor, so a grab never jumps the value.
|
||||
double dragKnobStartValue_ = 0.0;
|
||||
|
||||
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a
|
||||
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
|
||||
// Curve popup open flag, never persisted.
|
||||
bool curvePopupOpen_ = false;
|
||||
|
||||
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "shell/instrument/reasampler_embed.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -62,15 +63,6 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
|
||||
}
|
||||
#endif
|
||||
|
||||
// Projects the performance map into the strip's minimal zone shape (key ranges only).
|
||||
// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
@@ -80,26 +72,40 @@ tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
return kNoInterface;
|
||||
}
|
||||
|
||||
// The loaded capture id + effective root, both cheap in-process accessors.
|
||||
void ReaSamplerEmbed::refreshLoaded() {
|
||||
loadedId_ = processor_->selectedSampleId();
|
||||
const InstrumentParams params = processor_->instrumentParams();
|
||||
if (params.rootOverride) {
|
||||
rootNote_ = *params.rootOverride;
|
||||
} else {
|
||||
const SampleRefs refs = processor_->sampleRefs();
|
||||
if (const SelectedSample* ref = findRef(refs, loadedId_)) {
|
||||
rootNote_ = ref->rootNote;
|
||||
} else {
|
||||
rootNote_ = 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::refresh() {
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
loadedId_.clear();
|
||||
rootNote_ = 60;
|
||||
return;
|
||||
}
|
||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
refreshLoaded();
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor, and the editor may edit zones
|
||||
// with no bank-content change — always re-snapshot it so an edit reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// The loaded capture + root are cheap in-process accessors, and the editor may change
|
||||
// either with no bank-content change — always re-snapshot so an edit reflects at once.
|
||||
refreshLoaded();
|
||||
|
||||
// The expensive part is the bank-blob bridge read: gate it on the bank-generation
|
||||
// stamp, re-reading only when it changed (or on the first paint). A project with no
|
||||
@@ -157,9 +163,6 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
#ifdef _WIN32
|
||||
case REAPER_FXEMBED_WM_PAINT:
|
||||
return paint(parm2, parm3) ? 1 : 0;
|
||||
case REAPER_FXEMBED_WM_LBUTTONDOWN:
|
||||
// Selection at most: map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
return 0; // unhandled messages (cursor, wheel, hittest) fall through
|
||||
@@ -186,45 +189,35 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
|
||||
const EmbedLayout layout = layoutEmbed(w, h);
|
||||
|
||||
if (map_.zones.empty()) {
|
||||
// No opt-in zones authored: a faint band so the strip reads as "present, no zones"
|
||||
// — the default single-capture face lives in the editor.
|
||||
if (loadedId_.empty()) {
|
||||
// Nothing loaded: a faint band so the strip reads as "present, silent" — the pick
|
||||
// affordance lives in the editor.
|
||||
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
|
||||
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
|
||||
const std::string label = version::vstPluginName() + // channel-derived
|
||||
(samples_.empty() ? " (bank empty)" : " (no zones)");
|
||||
(samples_.empty() ? " (bank empty)" : " (pick a capture)");
|
||||
const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
|
||||
layout.keymap.bottom());
|
||||
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
|
||||
} else {
|
||||
// Each zone draws as a segment (first-match order, matching selection/playback),
|
||||
// colored by its key span's spectral hue so it reads as the same spectrum as the
|
||||
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow.
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[i];
|
||||
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
|
||||
if (r.width <= 0) continue;
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo, then the crisp accent-primary fill.
|
||||
LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0;
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(spectralColor(t)), 0.65f, 0);
|
||||
}
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
// Label when wide enough to read; the selected (accent-fill) segment labels in
|
||||
// bg/base for contrast, the rest in text/primary.
|
||||
if (r.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
|
||||
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left);
|
||||
}
|
||||
// The loaded capture spans the whole keyboard, drawn in its root's spectral hue so
|
||||
// the strip reads as the same spectrum as the editor's keyboard strip; the root key
|
||||
// lifts to accent-primary with a static glow ("this is where it plays at unity").
|
||||
const Rect span = keySpanRect(layout, 0, 127);
|
||||
LICE_FillRect(bmp, span.x, span.y, span.width, span.height,
|
||||
toLice(spectralColor(rootNote_ / 127.0)), 0.65f, 0);
|
||||
LICE_DrawRect(bmp, span.x, span.y, span.width - 1, span.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
const Rect root = keySpanRect(layout, rootNote_, rootNote_);
|
||||
const int rw = (std::max)(2, root.width);
|
||||
LICE_FillRect(bmp, root.x - 2, root.y, rw + 4, root.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, root.x, root.y, rw, root.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
if (span.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(span.x + 3, span.y, span.right() - 2, span.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, loadedId_).c_str(), Font::Label,
|
||||
Role::TextPrimary, Align::Left);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +236,6 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) {
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!di || di->width <= 0 || di->height <= 0) return false;
|
||||
refresh();
|
||||
const EmbedLayout layout = layoutEmbed(di->width, di->height);
|
||||
const std::vector<EmbedZone> zones = toEmbedZones(map_);
|
||||
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(zones.size()),
|
||||
di->mouse_x, di->mouse_y);
|
||||
if (hit == selectedZone_) return false; // no change -> no redraw
|
||||
selectedZone_ = hit;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in
|
||||
// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). Windows-only, guarded so a non-Windows build stays compilable.
|
||||
// The strip's layout + hit-test is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it.
|
||||
// The strip's layout is pure (embed_strip.h, unit-tested); this shell marshals REAPER's
|
||||
// messages to/from it. The strip is a read-only readout — the loaded capture across the
|
||||
// keyboard with its root marked, plus the activity level.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -13,7 +14,7 @@
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice (the state the strip reflects)
|
||||
|
||||
// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is
|
||||
// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
|
||||
@@ -26,7 +27,6 @@ namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleChoice;
|
||||
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
|
||||
@@ -59,17 +59,19 @@ private:
|
||||
#ifdef _WIN32
|
||||
// Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
|
||||
// A mouse-down inside the strip: maps to a zone and selects it (no new editing
|
||||
// semantics). Returns true if the selection changed (caller then invalidates).
|
||||
bool onMouseDown(Steinberg::TPtrInt drawInfo);
|
||||
#endif
|
||||
|
||||
// Snapshots the live bank + the instrument's performance map for the next paint.
|
||||
// The loaded capture id + effective root (cheap in-process accessors, no bridge read).
|
||||
// `processor_` must be non-null.
|
||||
void refreshLoaded();
|
||||
|
||||
// Snapshots the live bank + the instrument's loaded capture and effective root for the
|
||||
// next paint.
|
||||
void refresh();
|
||||
|
||||
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation
|
||||
// stamp changed since the last paint. The performance map is always refreshed (cheap
|
||||
// in-process accessor) so a zone edit reflects immediately. UI thread only.
|
||||
// stamp changed since the last paint. The loaded capture + root are always refreshed
|
||||
// (cheap in-process accessors) so an edit reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
@@ -77,10 +79,8 @@ private:
|
||||
// from a real generation 0, forcing the first maybeRefresh() to do a full read.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's
|
||||
// highlight.
|
||||
int selectedZone_ = -1;
|
||||
std::string loadedId_; // the loaded capture's bank id; "" = nothing loaded
|
||||
int rootNote_ = 60; // effective root: override, else the capture's own intrinsic
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -17,37 +17,37 @@
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split)
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using instrument::map::ComponentState;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::InstrumentParams;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// Decoded keymap + the voice engine playing it. The engine holds references into the
|
||||
// keymap, so both must live/die together at a stable address — heap-allocated,
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: keymap(std::move(km)),
|
||||
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
@@ -111,7 +111,7 @@ public:
|
||||
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
// Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV
|
||||
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
|
||||
// off-thread, and publishes the built instrument via atomic swap — no bank read
|
||||
// required. When the bank blob is readable it's first folded into the refs table
|
||||
// (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
|
||||
@@ -140,16 +140,16 @@ public:
|
||||
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
|
||||
// shares this time base. 0.0 before setupProcessing runs.
|
||||
double sampleRate() const { return sampleRate_; }
|
||||
// The single-capture selection id (guarded by selectionMutex_, never read on the audio
|
||||
// thread): the default face's pick when the performance map is empty; a non-empty map
|
||||
// supersedes it. Empty id -> silence, no first-sample fallback.
|
||||
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
|
||||
// Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never
|
||||
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
// The one parameter set governing that capture. UI thread, guarded by paramsMutex_;
|
||||
// never read on the audio thread — reloadInstrument bakes it into the SampleData
|
||||
// off-thread.
|
||||
InstrumentParams instrumentParams();
|
||||
void setInstrumentParams(const InstrumentParams& params);
|
||||
|
||||
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
|
||||
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
|
||||
@@ -165,8 +165,8 @@ public:
|
||||
void setPreviewVelocity(std::uint8_t velocity);
|
||||
|
||||
// Voice-system parameters (per-instance), guarded by voiceParamsMutex_, not read on the
|
||||
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded keymap, no
|
||||
// bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
|
||||
// audio thread — each setter rebuilds via rebuildVoiceEngine (already-decoded SampleData,
|
||||
// no bridge/WAV re-read) through the same drain-slot swap, so a change never cuts a tail.
|
||||
int voiceCount();
|
||||
void setVoiceCount(int count); // clamped to kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode();
|
||||
@@ -203,7 +203,7 @@ private:
|
||||
void retireIdleDrain();
|
||||
|
||||
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
|
||||
// already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot
|
||||
// already-decoded SampleData (no bridge/disk) and publishes through the same drain-slot
|
||||
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
|
||||
void rebuildVoiceEngine();
|
||||
|
||||
@@ -253,15 +253,15 @@ private:
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read
|
||||
// on the audio thread.
|
||||
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
|
||||
// audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into
|
||||
// the Keymap under the reload lock, never read directly on the audio thread.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
|
||||
// under the reload lock, never read directly on the audio thread.
|
||||
std::mutex paramsMutex_;
|
||||
InstrumentParams params_;
|
||||
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// and returning every index for an empty query.
|
||||
|
||||
#include "../src/core/instrument/ui/browser_scroll.h"
|
||||
// The modal reuses the Sample face's chrome metrics (kPad / kTitleHeight); assert against
|
||||
// those same constants so a metric change can never desync the sheet from what it covers.
|
||||
#include "../src/core/instrument/ui/sample_bands.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
+753
-149
@@ -1,14 +1,16 @@
|
||||
// component_state_io unit tests (Q-W2v). The HISTORICAL codec suite — the full
|
||||
// envelope/payload version ladder, every legacy lift, the golden byte fixtures —
|
||||
// lives in test_sample_map.cpp and runs unmodified against the split module; this
|
||||
// target exists as the module's OWN executable (house rule: every pure module has
|
||||
// one) and as the STRUCTURAL PROOF the codec links WITHOUT the voice engine
|
||||
// (T2-07): it links component_state_io + velocity_curve + master_gain only — a
|
||||
// sampler_core/pitch_shift symbol reaching this link is a regression.
|
||||
// component_state_io unit tests — the codec's whole suite: the current envelope + params
|
||||
// payload round-trip, the frozen prefix bytes, the ENVELOPE ladder (v1..v11) with each
|
||||
// version's documented lift, and the RETIRED-ZONE-PAYLOAD migration ladder (payload v1..v7
|
||||
// -> the one parameter set, adopting zone one). The codec's own executable is also the
|
||||
// STRUCTURAL PROOF it links WITHOUT the voice engine: it links component_state_io +
|
||||
// velocity_curve + master_gain only, so a sampler_core/pitch_shift symbol reaching this
|
||||
// link is a regression.
|
||||
|
||||
#include "../src/core/instrument/map/component_state_io.h"
|
||||
#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 wire cap)
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -25,7 +27,190 @@ static int failures = 0;
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// A full round-trip through the CURRENT envelope (v11): every field survives.
|
||||
// --- A writer for the RETIRED zone-list payloads -----------------------------
|
||||
//
|
||||
// The shipping codec no longer EMITS a zone list, so the migration ladder can only be
|
||||
// tested against bytes this suite lays out itself. These helpers mirror the frozen v1..v7
|
||||
// record shapes documented in component_state_io.h; if they and the reader ever disagree,
|
||||
// the migration tests below fail — which is the point.
|
||||
|
||||
namespace legacy {
|
||||
|
||||
static void u8v(std::vector<std::uint8_t>& out, std::uint8_t v) { out.push_back(v); }
|
||||
|
||||
static void u32v(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
|
||||
}
|
||||
|
||||
static void i64v(std::vector<std::uint8_t>& out, std::int64_t v) {
|
||||
const auto u = static_cast<std::uint64_t>(v);
|
||||
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((u >> (8 * i)) & 0xFF));
|
||||
}
|
||||
|
||||
static void f64v(std::vector<std::uint8_t>& out, double v) {
|
||||
std::uint64_t bits = 0;
|
||||
std::memcpy(&bits, &v, sizeof(bits));
|
||||
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>((bits >> (8 * i)) & 0xFF));
|
||||
}
|
||||
|
||||
static void strv(std::vector<std::uint8_t>& out, const std::string& s) {
|
||||
u32v(out, static_cast<std::uint32_t>(s.size()));
|
||||
out.insert(out.end(), s.begin(), s.end());
|
||||
}
|
||||
|
||||
// One zone's worth of the retired per-zone record, in the v7 (fullest) shape.
|
||||
struct Zone {
|
||||
std::string sampleId;
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootOverride = -1; // < 0 = absent
|
||||
bool hasLoopOverride = false;
|
||||
// The override's OWN hasLoop bit — distinct from hasLoopOverride above. An override can
|
||||
// itself say "disable the loop" (loopOverrideHasLoop = false): the field is present but
|
||||
// sets no sustain loop, as opposed to no override at all (the sample's own intrinsic loop
|
||||
// applies). Defaults true so existing callers that only set hasLoopOverride keep writing
|
||||
// the enabled-loop shape they always did.
|
||||
bool loopOverrideHasLoop = true;
|
||||
std::int64_t loopStart = 0;
|
||||
std::int64_t loopEnd = 0;
|
||||
std::int64_t startPoint = -1; // < 0 = absent
|
||||
bool trigger = false;
|
||||
double holdSeconds = 0.0;
|
||||
double lengthFraction = 1.0;
|
||||
std::int64_t fadeIn = 0;
|
||||
std::int64_t fadeOut = 0;
|
||||
bool preserve = false;
|
||||
bool pitchEnvEnabled = false;
|
||||
double pitchAttack = 0.0;
|
||||
double pitchDecay = 0.0;
|
||||
double peakSemis = 0.0;
|
||||
double attackSeconds = 0.003;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
double keyTrack = 1.0;
|
||||
std::vector<VelocityPoint> curve; // empty -> the flat endpoints
|
||||
};
|
||||
|
||||
static void putZone(std::vector<std::uint8_t>& out, const Zone& z, std::uint32_t pv) {
|
||||
strv(out, z.sampleId);
|
||||
u32v(out, static_cast<std::uint32_t>(z.lowNote));
|
||||
u32v(out, static_cast<std::uint32_t>(z.highNote));
|
||||
u8v(out, z.rootOverride >= 0 ? 1 : 0);
|
||||
if (z.rootOverride >= 0) u32v(out, static_cast<std::uint32_t>(z.rootOverride));
|
||||
if (pv >= 2) {
|
||||
u8v(out, z.hasLoopOverride ? 1 : 0);
|
||||
if (z.hasLoopOverride) {
|
||||
u8v(out, z.loopOverrideHasLoop ? 1 : 0);
|
||||
i64v(out, z.loopStart);
|
||||
i64v(out, z.loopEnd);
|
||||
}
|
||||
u8v(out, z.startPoint >= 0 ? 1 : 0);
|
||||
if (z.startPoint >= 0) i64v(out, z.startPoint);
|
||||
}
|
||||
if (pv >= 5) {
|
||||
u8v(out, z.trigger ? 1 : 0);
|
||||
f64v(out, z.holdSeconds);
|
||||
f64v(out, z.lengthFraction);
|
||||
i64v(out, z.fadeIn);
|
||||
i64v(out, z.fadeOut);
|
||||
u8v(out, z.preserve ? 1 : 0);
|
||||
u8v(out, z.pitchEnvEnabled ? 1 : 0);
|
||||
f64v(out, z.pitchAttack);
|
||||
f64v(out, z.pitchDecay);
|
||||
f64v(out, z.peakSemis);
|
||||
f64v(out, z.attackSeconds);
|
||||
f64v(out, z.decaySeconds);
|
||||
f64v(out, z.sustainLevel);
|
||||
f64v(out, z.releaseSeconds);
|
||||
}
|
||||
if (pv >= 6) f64v(out, z.keyTrack);
|
||||
if (pv >= 7) {
|
||||
const std::vector<VelocityPoint> pts =
|
||||
z.curve.empty() ? std::vector<VelocityPoint>{{0.0, 1.0}, {127.0, 1.0}} : z.curve;
|
||||
u32v(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) { f64v(out, p.velocity); f64v(out, p.amp); }
|
||||
}
|
||||
}
|
||||
|
||||
// The envelope fields, in wire order. A builder at version N emits only the prefix fields
|
||||
// version N carried, so each lift can be asserted against a blob shaped exactly as that
|
||||
// version's writer produced.
|
||||
struct Envelope {
|
||||
std::uint32_t version = kComponentStateVersion;
|
||||
std::uint8_t modeByte = 0; // v4+ 0 mono / 1 stereo
|
||||
std::int64_t assignGeneration = 0; // v5+
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault; // v6+
|
||||
std::uint8_t voiceCount = static_cast<std::uint8_t>(kDefaultVoiceCount); // v7+
|
||||
std::uint8_t voiceMode = 0; // v7+ 0 poly / 1 mono
|
||||
std::uint8_t monoTrigger = 0; // v7+ 0 retrigger / 1 legato
|
||||
double masterGain = 1.0; // v8+
|
||||
std::uint8_t channelModeExplicit = 0; // v9+
|
||||
std::string instanceGuid; // v11+
|
||||
std::string selectionId; // v3+
|
||||
};
|
||||
|
||||
// A complete envelope at `env.version` whose tail is a RETIRED zone-list payload at version
|
||||
// `pv`. `env.selectionId` is the envelope's own stored pick — which the adoption rule
|
||||
// overrides when the payload carries a zone. The sample-refs table (v10+) is always empty:
|
||||
// its own shape is covered by the round-trip test.
|
||||
static std::vector<std::uint8_t> envelopeWithZones(const Envelope& env,
|
||||
const std::vector<Zone>& zones,
|
||||
std::uint32_t pv) {
|
||||
std::vector<std::uint8_t> out;
|
||||
const std::uint32_t v = env.version;
|
||||
u32v(out, v);
|
||||
if (v >= 4) u8v(out, env.modeByte);
|
||||
if (v >= 5) i64v(out, env.assignGeneration);
|
||||
if (v >= 6) u8v(out, env.previewVelocity);
|
||||
if (v >= 7) { u8v(out, env.voiceCount); u8v(out, env.voiceMode); u8v(out, env.monoTrigger); }
|
||||
if (v >= 8) f64v(out, env.masterGain);
|
||||
if (v >= 9) u8v(out, env.channelModeExplicit);
|
||||
if (v >= 10) u32v(out, 0); // sample-refs: empty table
|
||||
if (v >= 11) strv(out, env.instanceGuid);
|
||||
if (v >= 3) strv(out, env.selectionId); // v2 was zones-only, no selection
|
||||
if (pv >= 2) {
|
||||
u32v(out, kParamsFormatMarker);
|
||||
u32v(out, pv);
|
||||
}
|
||||
u32v(out, static_cast<std::uint32_t>(zones.size()));
|
||||
for (const Zone& z : zones) putZone(out, z, pv);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shorthand for the common case: the CURRENT envelope version carrying a zone payload.
|
||||
static std::vector<std::uint8_t> envelopeWithZones(const std::string& selectionId,
|
||||
const std::vector<Zone>& zones,
|
||||
std::uint32_t pv) {
|
||||
Envelope env;
|
||||
env.selectionId = selectionId;
|
||||
return envelopeWithZones(env, zones, pv);
|
||||
}
|
||||
|
||||
} // namespace legacy
|
||||
|
||||
// --- The current format -------------------------------------------------------
|
||||
|
||||
// Builds a SampleRefEntry with the intrinsics fields the refs-robustness tests below need to
|
||||
// set individually (root/loop/channels), mirroring the codec's own field names.
|
||||
static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root,
|
||||
bool hasLoop = false, std::int64_t loopStart = 0,
|
||||
std::int64_t loopEnd = 0, int channels = 0,
|
||||
const std::string& name = "") {
|
||||
SampleRefEntry e;
|
||||
e.sampleId = id;
|
||||
e.ref.relativePath = rel;
|
||||
e.ref.rootNote = root;
|
||||
e.ref.loop.hasLoop = hasLoop;
|
||||
e.ref.loop.start = loopStart;
|
||||
e.ref.loop.end = loopEnd;
|
||||
e.ref.channelCount = channels;
|
||||
e.displayName = name;
|
||||
return e;
|
||||
}
|
||||
|
||||
// A full round-trip through the CURRENT envelope (v11) + params payload (v8): every field
|
||||
// survives. This is the "one parameter set round-trips save/reload intact" contract.
|
||||
static void testComponentStateRoundTrip() {
|
||||
ComponentState in;
|
||||
in.selectionId = "smp-1";
|
||||
@@ -48,18 +233,30 @@ static void testComponentStateRoundTrip() {
|
||||
e.ref.channelCount = 2;
|
||||
e.displayName = "My Capture";
|
||||
in.sampleRefs.push_back(e);
|
||||
PerformanceZone z;
|
||||
z.sampleId = "smp-1";
|
||||
z.lowNote = 30;
|
||||
z.highNote = 90;
|
||||
z.rootOverride = 61;
|
||||
z.startPoint = 5;
|
||||
z.keyTrack = 1.5;
|
||||
z.play.playMode = PlayMode::Trigger;
|
||||
z.play.trigger.lengthFraction = 0.75;
|
||||
z.play.trigger.fadeInFrames = 441;
|
||||
z.play.trigger.fadeOutFrames = 882;
|
||||
in.map.zones.push_back(z);
|
||||
in.params.rootOverride = 61;
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = true;
|
||||
lp.start = 7;
|
||||
lp.end = 900;
|
||||
in.params.loopOverride = lp;
|
||||
in.params.startPoint = 5;
|
||||
in.params.keyTrack = 1.5;
|
||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
||||
in.params.play.playMode = PlayMode::Trigger;
|
||||
in.params.play.adsr.attackSeconds = 0.01;
|
||||
in.params.play.adsr.holdSeconds = 0.05;
|
||||
in.params.play.adsr.decaySeconds = 0.02;
|
||||
in.params.play.adsr.sustainLevel = 0.8;
|
||||
in.params.play.adsr.releaseSeconds = 0.15;
|
||||
in.params.play.trigger.lengthFraction = 0.75;
|
||||
in.params.play.trigger.fadeInFrames = 441;
|
||||
in.params.play.trigger.fadeOutFrames = 882;
|
||||
in.params.play.pitchEngine = PitchEngine::Preserve;
|
||||
in.params.play.pitchEnv.enabled = true;
|
||||
in.params.play.pitchEnv.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.peakSemitones = 5.0;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
const ComponentState out = deserializeComponentState(bytes, 48000.0);
|
||||
@@ -85,32 +282,38 @@ static void testComponentStateRoundTrip() {
|
||||
CHECK(out.sampleRefs[0].ref.channelCount == 2);
|
||||
CHECK(out.sampleRefs[0].displayName == "My Capture");
|
||||
}
|
||||
CHECK(out.map.zones.size() == 1);
|
||||
if (out.map.zones.size() == 1) {
|
||||
const PerformanceZone& oz = out.map.zones[0];
|
||||
CHECK(oz.sampleId == "smp-1");
|
||||
CHECK(oz.lowNote == 30);
|
||||
CHECK(oz.highNote == 90);
|
||||
CHECK(oz.rootOverride && *oz.rootOverride == 61);
|
||||
CHECK(oz.startPoint && *oz.startPoint == 5);
|
||||
CHECK(oz.keyTrack == 1.5);
|
||||
CHECK(oz.play.playMode == PlayMode::Trigger);
|
||||
CHECK(oz.play.trigger.lengthFraction == 0.75);
|
||||
CHECK(oz.play.trigger.fadeInFrames == 441);
|
||||
CHECK(oz.play.trigger.fadeOutFrames == 882);
|
||||
}
|
||||
const InstrumentParams& p = out.params;
|
||||
CHECK(p.rootOverride && *p.rootOverride == 61);
|
||||
CHECK(p.loopOverride && p.loopOverride->hasLoop);
|
||||
CHECK(p.loopOverride && p.loopOverride->start == 7 && p.loopOverride->end == 900);
|
||||
CHECK(p.startPoint && *p.startPoint == 5);
|
||||
CHECK(p.keyTrack == 1.5);
|
||||
CHECK(p.velocityCurve.points().size() == 3);
|
||||
CHECK(p.play.playMode == PlayMode::Trigger);
|
||||
CHECK(p.play.adsr.attackSeconds == 0.01);
|
||||
CHECK(p.play.adsr.holdSeconds == 0.05);
|
||||
CHECK(p.play.adsr.decaySeconds == 0.02);
|
||||
CHECK(p.play.adsr.sustainLevel == 0.8);
|
||||
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
||||
CHECK(p.play.trigger.lengthFraction == 0.75);
|
||||
CHECK(p.play.trigger.fadeInFrames == 441);
|
||||
CHECK(p.play.trigger.fadeOutFrames == 882);
|
||||
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
||||
CHECK(p.play.pitchEnv.enabled);
|
||||
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
}
|
||||
|
||||
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up, Q-W2v). testEnvelopePrefixBytesFrozen below
|
||||
// only pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the
|
||||
// mode byte (a field re-ordered or dropped inside the voice/gain/refs/guid/zone tail would
|
||||
// still pass it). This test builds a canonical v11 ComponentState that exercises EVERY field
|
||||
// family at once (two zones — one Trigger with every optional override set, one Gate with all
|
||||
// optionals absent — a two-entry sample-refs table, non-default voice/gain/channel-mode
|
||||
// fields, and a non-flat velocity curve) and asserts the encoded bytes equal an EXACT expected
|
||||
// vector. The vector below is the current writer's PROVABLY-CORRECT output (proven by the
|
||||
// round-trip test above) captured as the golden — so the byte layout itself becomes
|
||||
// un-driftable, not just its first 5 bytes.
|
||||
// GOLDEN FULL-BLOB FIXTURE (reviewer follow-up). testEnvelopePrefixBytesFrozen below only
|
||||
// pins the first 5 bytes of a near-EMPTY blob; it cannot catch a drift anywhere past the mode
|
||||
// byte (a field re-ordered or dropped inside the voice/gain/refs/guid/params tail would still
|
||||
// pass it). This builds a canonical v11 ComponentState/v8-params blob that exercises every
|
||||
// field family at once (a two-entry sample-refs table — one with a loop, one without — every
|
||||
// optional param field present, a non-flat velocity curve, Trigger mode with a pitch envelope)
|
||||
// and asserts the encoded bytes equal an EXACT expected vector, captured from the current
|
||||
// writer's output and checked field-for-field against the v8/v11 layout documented in
|
||||
// component_state_io.h.
|
||||
static void testGoldenFullBlobFixture() {
|
||||
ComponentState in;
|
||||
in.selectionId = "kick";
|
||||
@@ -146,51 +349,30 @@ static void testGoldenFullBlobFixture() {
|
||||
snareRef.displayName = "Snare";
|
||||
in.sampleRefs.push_back(snareRef);
|
||||
|
||||
// Zone A: every optional field present, Trigger mode, non-flat velocity curve.
|
||||
PerformanceZone zoneA;
|
||||
zoneA.sampleId = "kick";
|
||||
zoneA.lowNote = 24;
|
||||
zoneA.highNote = 60;
|
||||
zoneA.rootOverride = 36;
|
||||
in.params.rootOverride = 36;
|
||||
SampleLoop loopA;
|
||||
loopA.hasLoop = true;
|
||||
loopA.start = 1000;
|
||||
loopA.end = 5000;
|
||||
zoneA.loopOverride = loopA;
|
||||
zoneA.startPoint = 250;
|
||||
zoneA.keyTrack = 0.5;
|
||||
zoneA.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
in.params.loopOverride = loopA;
|
||||
in.params.startPoint = 250;
|
||||
in.params.keyTrack = 0.5;
|
||||
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
|
||||
{VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}});
|
||||
zoneA.play.playMode = PlayMode::Trigger;
|
||||
zoneA.play.adsr.attackSeconds = 0.01;
|
||||
zoneA.play.adsr.holdSeconds = 0.05;
|
||||
zoneA.play.adsr.decaySeconds = 0.02;
|
||||
zoneA.play.adsr.sustainLevel = 0.8;
|
||||
zoneA.play.adsr.releaseSeconds = 0.15;
|
||||
zoneA.play.trigger.lengthFraction = 0.75;
|
||||
zoneA.play.trigger.fadeInFrames = 100;
|
||||
zoneA.play.trigger.fadeOutFrames = 200;
|
||||
zoneA.play.pitchEngine = PitchEngine::Preserve;
|
||||
zoneA.play.pitchEnv.enabled = true;
|
||||
zoneA.play.pitchEnv.attackSeconds = 0.02;
|
||||
zoneA.play.pitchEnv.decaySeconds = 0.03;
|
||||
zoneA.play.pitchEnv.peakSemitones = 5.0;
|
||||
in.map.zones.push_back(zoneA);
|
||||
|
||||
// Zone B: every optional field absent, Gate mode, default flat velocity curve.
|
||||
PerformanceZone zoneB;
|
||||
zoneB.sampleId = "snare";
|
||||
zoneB.lowNote = 61;
|
||||
zoneB.highNote = 90;
|
||||
zoneB.keyTrack = 2.0;
|
||||
zoneB.play.playMode = PlayMode::Gate;
|
||||
zoneB.play.adsr.attackSeconds = 0.005;
|
||||
zoneB.play.adsr.holdSeconds = 0.0;
|
||||
zoneB.play.adsr.decaySeconds = 0.1;
|
||||
zoneB.play.adsr.sustainLevel = 0.5;
|
||||
zoneB.play.adsr.releaseSeconds = 0.2;
|
||||
zoneB.play.pitchEngine = PitchEngine::Varispeed;
|
||||
in.map.zones.push_back(zoneB);
|
||||
in.params.play.playMode = PlayMode::Trigger;
|
||||
in.params.play.adsr.attackSeconds = 0.01;
|
||||
in.params.play.adsr.holdSeconds = 0.05;
|
||||
in.params.play.adsr.decaySeconds = 0.02;
|
||||
in.params.play.adsr.sustainLevel = 0.8;
|
||||
in.params.play.adsr.releaseSeconds = 0.15;
|
||||
in.params.play.trigger.lengthFraction = 0.75;
|
||||
in.params.play.trigger.fadeInFrames = 100;
|
||||
in.params.play.trigger.fadeOutFrames = 200;
|
||||
in.params.play.pitchEngine = PitchEngine::Preserve;
|
||||
in.params.play.pitchEnv.enabled = true;
|
||||
in.params.play.pitchEnv.attackSeconds = 0.02;
|
||||
in.params.play.pitchEnv.decaySeconds = 0.03;
|
||||
in.params.play.pitchEnv.peakSemitones = 5.0;
|
||||
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
// clang-format off
|
||||
@@ -206,30 +388,19 @@ static void testGoldenFullBlobFixture() {
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
|
||||
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
|
||||
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x07,0x00,0x00,
|
||||
0x00,0x02,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x18,0x00,0x00,
|
||||
0x00,0x3c,0x00,0x00,0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
|
||||
0xb8,0x1e,0x85,0xeb,0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,
|
||||
0x7b,0x14,0xae,0x47,0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,
|
||||
0x9a,0x99,0x99,0x99,0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x50,0x40,0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,
|
||||
0x00,0xc0,0x5f,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x05,0x00,0x00,0x00,
|
||||
0x73,0x6e,0x61,0x72,0x65,0x3d,0x00,0x00,0x00,0x5a,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,
|
||||
0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7b,0x14,0xae,0x47,0xe1,
|
||||
0x7a,0x74,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xb9,0x3f,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0xe0,0x3f,0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x40,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,0x00,
|
||||
0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x08,0x00,0x00,
|
||||
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0xe8,0x3f,0x64,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0x00,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0x01,0x01,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0xb8,0x1e,0x85,0xeb,
|
||||
0x51,0xb8,0x9e,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x14,0x40,0x7b,0x14,0xae,0x47,
|
||||
0xe1,0x7a,0x84,0x3f,0x7b,0x14,0xae,0x47,0xe1,0x7a,0x94,0x3f,0x9a,0x99,0x99,0x99,
|
||||
0x99,0x99,0xe9,0x3f,0x33,0x33,0x33,0x33,0x33,0x33,0xc3,0x3f,0x00,0x00,0x00,0x00,
|
||||
0x00,0x00,0xe0,0x3f,0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
0x9a,0x99,0x99,0x99,0x99,0x99,0xc9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0x40,
|
||||
0x33,0x33,0x33,0x33,0x33,0x33,0xe3,0x3f,0x00,0x00,0x00,0x00,0x00,0xc0,0x5f,0x40,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f,
|
||||
};
|
||||
// clang-format on
|
||||
CHECK(bytes.size() == sizeof(kGolden));
|
||||
@@ -242,11 +413,29 @@ static void testGoldenFullBlobFixture() {
|
||||
}
|
||||
}
|
||||
|
||||
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in
|
||||
// either is a byte-format break the round-trip alone can't prove (both sides could
|
||||
// drift together). Pins the writer's absolute bytes.
|
||||
// A DEFAULT parameter set must round-trip to defaults — the "no pick, nothing configured"
|
||||
// blob restores as the silent empty state, not as a set of accidental values.
|
||||
static void testDefaultStateRoundTripsToDefaults() {
|
||||
const ComponentState out =
|
||||
deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0);
|
||||
CHECK(out.selectionId.empty());
|
||||
CHECK(!out.params.rootOverride);
|
||||
CHECK(!out.params.loopOverride);
|
||||
CHECK(!out.params.startPoint);
|
||||
CHECK(out.params.keyTrack == 1.0);
|
||||
CHECK(out.params.play.playMode == PlayMode::Gate);
|
||||
CHECK(out.params.play.pitchEngine == kDefaultPitchEngine);
|
||||
CHECK(!out.params.play.pitchEnv.enabled);
|
||||
CHECK(out.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
|
||||
CHECK(out.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
|
||||
}
|
||||
|
||||
// The FROZEN envelope prefix: version tag v11 LE, then the mode byte — a drift in either is
|
||||
// a byte-format break the round-trip alone can't prove (both sides could drift together).
|
||||
// Also pins the payload version + marker as SEMANTIC constants, so a bump has to be
|
||||
// deliberate rather than incidental.
|
||||
static void testEnvelopePrefixBytesFrozen() {
|
||||
ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones
|
||||
ComponentState in; // defaults: mono, implicit, no refs, no selection, default params
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
CHECK(bytes.size() > 5);
|
||||
if (bytes.size() > 5) {
|
||||
@@ -254,64 +443,479 @@ static void testEnvelopePrefixBytesFrozen() {
|
||||
CHECK(bytes[4] == 0); // ChannelMode::Mono
|
||||
}
|
||||
CHECK(kComponentStateVersion == 11);
|
||||
CHECK(kZonesPayloadVersion == 7);
|
||||
CHECK(kZonesFormatMarker == 0xFFFFFF00u);
|
||||
CHECK(kParamsPayloadVersion == 8);
|
||||
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
|
||||
}
|
||||
|
||||
// A v1 selection blob lifts to {id, one full-keyboard zone} — the oldest live lift.
|
||||
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
|
||||
// the payload — the self-describing property every legacy branch depends on. Asserted
|
||||
// against the semantic constants, not literals.
|
||||
static void testWriterEmitsCurrentPayloadVersion() {
|
||||
ComponentState in;
|
||||
in.selectionId = "id";
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
// Scan for the marker; the four bytes after it are the payload version.
|
||||
bool found = false;
|
||||
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
|
||||
const std::uint32_t m = static_cast<std::uint32_t>(bytes[i]) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
|
||||
if (m != kParamsFormatMarker) continue;
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[i + 4]) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[i + 7]) << 24);
|
||||
CHECK(v == kParamsPayloadVersion);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
CHECK(found);
|
||||
}
|
||||
|
||||
// --- The retired-zone-payload migration ladder --------------------------------
|
||||
|
||||
// SINGLE-ZONE LIFT IS LOSSLESS: a single-capture instance saved under the zone model
|
||||
// restores with the same capture, the same root, and the same parameters.
|
||||
static void testSingleZoneMigrationIsLossless() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.rootOverride = 36;
|
||||
z.hasLoopOverride = true;
|
||||
z.loopStart = 1000;
|
||||
z.loopEnd = 5000;
|
||||
z.startPoint = 250;
|
||||
z.trigger = true;
|
||||
z.holdSeconds = 0.05;
|
||||
z.lengthFraction = 0.75;
|
||||
z.fadeIn = 100;
|
||||
z.fadeOut = 200;
|
||||
z.preserve = true;
|
||||
z.pitchEnvEnabled = true;
|
||||
z.pitchAttack = 0.02;
|
||||
z.pitchDecay = 0.03;
|
||||
z.peakSemis = 5.0;
|
||||
z.attackSeconds = 0.01;
|
||||
z.decaySeconds = 0.02;
|
||||
z.sustainLevel = 0.8;
|
||||
z.releaseSeconds = 0.15;
|
||||
z.keyTrack = 0.5;
|
||||
z.curve = {VelocityPoint{0.0, 0.2}, VelocityPoint{64.0, 0.6}, VelocityPoint{127.0, 1.0}};
|
||||
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
||||
|
||||
CHECK(out.selectionId == "kick"); // same capture
|
||||
const InstrumentParams& p = out.params;
|
||||
CHECK(p.rootOverride && *p.rootOverride == 36); // same root
|
||||
CHECK(p.loopOverride && p.loopOverride->start == 1000 && p.loopOverride->end == 5000);
|
||||
CHECK(p.startPoint && *p.startPoint == 250);
|
||||
CHECK(p.keyTrack == 0.5);
|
||||
CHECK(p.velocityCurve.points().size() == 3);
|
||||
CHECK(p.play.playMode == PlayMode::Trigger);
|
||||
CHECK(p.play.adsr.attackSeconds == 0.01);
|
||||
CHECK(p.play.adsr.holdSeconds == 0.05);
|
||||
CHECK(p.play.adsr.decaySeconds == 0.02);
|
||||
CHECK(p.play.adsr.sustainLevel == 0.8);
|
||||
CHECK(p.play.adsr.releaseSeconds == 0.15);
|
||||
CHECK(p.play.trigger.lengthFraction == 0.75);
|
||||
CHECK(p.play.trigger.fadeInFrames == 100);
|
||||
CHECK(p.play.trigger.fadeOutFrames == 200);
|
||||
CHECK(p.play.pitchEngine == PitchEngine::Preserve);
|
||||
CHECK(p.play.pitchEnv.enabled);
|
||||
CHECK(p.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(p.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(p.play.pitchEnv.peakSemitones == 5.0);
|
||||
}
|
||||
|
||||
// A legacy OVERRIDE THAT DISABLES THE LOOP migrates as a PRESENT loopOverride with hasLoop
|
||||
// false — distinct from no override at all (which leaves the sample's own intrinsic loop in
|
||||
// force). The writer always emitted the override's inner hasLoop bit as true; this is the
|
||||
// disabled shape it never exercised.
|
||||
static void testSingleZoneMigrationLiftsLoopDisablingOverride() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.hasLoopOverride = true;
|
||||
z.loopOverrideHasLoop = false;
|
||||
z.loopStart = 1000;
|
||||
z.loopEnd = 5000;
|
||||
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
||||
|
||||
const InstrumentParams& p = out.params;
|
||||
CHECK(p.loopOverride.has_value());
|
||||
CHECK(p.loopOverride && !p.loopOverride->hasLoop);
|
||||
}
|
||||
|
||||
// A lifted single-zone instance RE-SAVES in the current format and survives a second
|
||||
// round-trip unchanged — the lift is a one-way door, not a per-open re-derivation.
|
||||
static void testLiftedStateReSavesInCurrentFormat() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.rootOverride = 36;
|
||||
z.keyTrack = 0.5;
|
||||
z.releaseSeconds = 0.4;
|
||||
const ComponentState lifted =
|
||||
deserializeComponentState(legacy::envelopeWithZones("kick", {z}, 7), 48000.0);
|
||||
const ComponentState again =
|
||||
deserializeComponentState(serializeComponentState(lifted), 48000.0);
|
||||
CHECK(again.selectionId == "kick");
|
||||
CHECK(again.params.rootOverride && *again.params.rootOverride == 36);
|
||||
CHECK(again.params.keyTrack == 0.5);
|
||||
CHECK(again.params.play.adsr.releaseSeconds == 0.4);
|
||||
}
|
||||
|
||||
// MULTI-ZONE LIFT ADOPTS ZONE ONE: its capture AND its parameters win; every later zone
|
||||
// drops. No error, no empty state.
|
||||
static void testMultiZoneMigrationAdoptsFirstZone() {
|
||||
legacy::Zone first;
|
||||
first.sampleId = "kick";
|
||||
first.lowNote = 0;
|
||||
first.highNote = 59;
|
||||
first.rootOverride = 36;
|
||||
first.keyTrack = 0.5;
|
||||
first.releaseSeconds = 0.4;
|
||||
|
||||
legacy::Zone second;
|
||||
second.sampleId = "snare";
|
||||
second.lowNote = 60;
|
||||
second.highNote = 127;
|
||||
second.rootOverride = 38;
|
||||
second.keyTrack = 2.0;
|
||||
second.releaseSeconds = 0.9;
|
||||
|
||||
legacy::Zone third;
|
||||
third.sampleId = "hat";
|
||||
third.rootOverride = 42;
|
||||
|
||||
const ComponentState out = deserializeComponentState(
|
||||
legacy::envelopeWithZones("", {first, second, third}, 7), 48000.0);
|
||||
|
||||
CHECK(out.selectionId == "kick"); // zone one's capture
|
||||
CHECK(out.params.rootOverride && *out.params.rootOverride == 36);
|
||||
CHECK(out.params.keyTrack == 0.5); // zone one's parameters
|
||||
CHECK(out.params.play.adsr.releaseSeconds == 0.4);
|
||||
// Zones two and three left no trace anywhere.
|
||||
CHECK(out.selectionId != "snare" && out.selectionId != "hat");
|
||||
CHECK(out.params.keyTrack != 2.0);
|
||||
}
|
||||
|
||||
// The FIRST zone supersedes the envelope's own stored selection — that zone is what
|
||||
// first-match resolve actually played, so adopting it is what keeps the sound identical.
|
||||
static void testFirstZoneSupersedesStoredSelection() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "actually-playing";
|
||||
const ComponentState out = deserializeComponentState(
|
||||
legacy::envelopeWithZones("stale-selection", {z}, 7), 48000.0);
|
||||
CHECK(out.selectionId == "actually-playing");
|
||||
}
|
||||
|
||||
// An EMPTY zone list leaves the envelope's selection alone (a picked-but-never-edited
|
||||
// instance) and yields default parameters.
|
||||
static void testEmptyZoneListKeepsTheStoredSelection() {
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("picked", {}, 7), 48000.0);
|
||||
CHECK(out.selectionId == "picked");
|
||||
CHECK(!out.params.rootOverride);
|
||||
CHECK(out.params.keyTrack == 1.0);
|
||||
}
|
||||
|
||||
// EVERY older payload version takes the migration path, and each lifts the fields its own
|
||||
// shape carries while defaulting the ones it predates.
|
||||
static void testEveryOlderPayloadVersionMigrates() {
|
||||
for (std::uint32_t pv : {1u, 2u, 5u, 6u, 7u}) {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.rootOverride = 36;
|
||||
z.keyTrack = 0.5;
|
||||
z.releaseSeconds = 0.4;
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones("", {z}, pv), 48000.0);
|
||||
CHECK(out.selectionId == "kick"); // every version
|
||||
CHECK(out.params.rootOverride && *out.params.rootOverride == 36); // v1 onward
|
||||
// keyTrack arrived at v6; older payloads lift to 100% ET (bit-identical repitch).
|
||||
CHECK(out.params.keyTrack == (pv >= 6 ? 0.5 : 1.0));
|
||||
// The full A/D/S/R tail arrived at v5; older payloads keep the tier-0 defaults.
|
||||
CHECK(out.params.play.adsr.releaseSeconds ==
|
||||
(pv >= 5 ? 0.4 : AdsrSeconds{}.releaseSeconds));
|
||||
}
|
||||
// And the CURRENT version does NOT take the migration path: it reads its own record.
|
||||
CHECK(kParamsPayloadVersion == 8);
|
||||
}
|
||||
|
||||
// The LEGACY v3 payload's wall-clock frame counts convert to seconds at the READ boundary
|
||||
// using the project rate threaded in — no baked constant.
|
||||
static void testLegacyV3FramesConvertAtTheProjectRate() {
|
||||
// v3's own tail shape differs from v5's, so lay it out directly here.
|
||||
std::vector<std::uint8_t> out;
|
||||
legacy::u32v(out, kComponentStateVersion);
|
||||
legacy::u8v(out, 0);
|
||||
legacy::i64v(out, 0);
|
||||
legacy::u8v(out, kPreviewVelocityDefault);
|
||||
legacy::u8v(out, static_cast<std::uint8_t>(kDefaultVoiceCount));
|
||||
legacy::u8v(out, 0);
|
||||
legacy::u8v(out, 0);
|
||||
legacy::f64v(out, 1.0);
|
||||
legacy::u8v(out, 0);
|
||||
legacy::u32v(out, 0);
|
||||
legacy::strv(out, "");
|
||||
legacy::strv(out, "");
|
||||
legacy::u32v(out, kParamsFormatMarker);
|
||||
legacy::u32v(out, 3);
|
||||
legacy::u32v(out, 1); // one zone
|
||||
legacy::strv(out, "kick");
|
||||
legacy::u32v(out, 0); // lowNote
|
||||
legacy::u32v(out, 127); // highNote
|
||||
legacy::u8v(out, 0); // no root override
|
||||
legacy::u8v(out, 0); // no loop override
|
||||
legacy::u8v(out, 0); // no start point
|
||||
legacy::u8v(out, 0); // playMode: Gate
|
||||
legacy::i64v(out, 2400); // holdFrames -> 0.05 s at 48 kHz
|
||||
legacy::f64v(out, 1.0); // lengthFraction
|
||||
legacy::i64v(out, 0); // fadeIn
|
||||
legacy::i64v(out, 0); // fadeOut
|
||||
legacy::u8v(out, 1); // pitchEngine: Preserve
|
||||
legacy::u8v(out, 1); // pitchEnv enabled
|
||||
legacy::i64v(out, 960); // pitchEnv attackFrames -> 0.02 s
|
||||
legacy::i64v(out, 1440); // pitchEnv decayFrames -> 0.03 s
|
||||
legacy::f64v(out, 5.0); // peakSemitones
|
||||
|
||||
const ComponentState st = deserializeComponentState(out, 48000.0);
|
||||
CHECK(st.selectionId == "kick");
|
||||
CHECK(st.params.play.adsr.holdSeconds == 0.05);
|
||||
CHECK(st.params.play.pitchEnv.attackSeconds == 0.02);
|
||||
CHECK(st.params.play.pitchEnv.decaySeconds == 0.03);
|
||||
CHECK(st.params.play.pitchEnv.peakSemitones == 5.0);
|
||||
// A/D/S/R are absent in v3 -> the tier-0 seconds defaults hold.
|
||||
CHECK(st.params.play.adsr.attackSeconds == AdsrSeconds{}.attackSeconds);
|
||||
CHECK(st.params.play.adsr.releaseSeconds == AdsrSeconds{}.releaseSeconds);
|
||||
|
||||
// The SAME bytes at a different project rate convert to different seconds — proof the
|
||||
// rate is a read-time parameter, not a baked constant.
|
||||
const ComponentState at96k = deserializeComponentState(out, 96000.0);
|
||||
CHECK(at96k.params.play.adsr.holdSeconds == 0.025);
|
||||
}
|
||||
|
||||
// --- The ENVELOPE ladder (v2..v11) -------------------------------------------
|
||||
|
||||
// EVERY envelope version restores the fields it carried and lifts the ones it predates to
|
||||
// their documented defaults. One table over the whole ladder, so a new envelope field
|
||||
// cannot be added without deciding what each older version lifts it to.
|
||||
static void testEnvelopeLadderLiftsEachVersion() {
|
||||
for (std::uint32_t v : {3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u}) {
|
||||
legacy::Envelope env;
|
||||
env.version = v;
|
||||
env.selectionId = "kick";
|
||||
env.modeByte = 1; // stereo
|
||||
env.assignGeneration = 4242;
|
||||
env.previewVelocity = 99;
|
||||
env.voiceCount = 7;
|
||||
env.voiceMode = 1; // mono
|
||||
env.monoTrigger = 1; // legato
|
||||
env.masterGain = 0.5;
|
||||
env.channelModeExplicit = 1;
|
||||
env.instanceGuid = "guid-abc";
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
||||
|
||||
CHECK(out.selectionId == "kick"); // v3 onward all carry the selection
|
||||
// v4 added the channel mode; older blobs lift to MONO.
|
||||
CHECK(out.channelMode == (v >= 4 ? ChannelMode::Stereo : ChannelMode::Mono));
|
||||
// v5 added the consumed-assignment marker; older blobs lift to 0, so a genuinely
|
||||
// new first assign (generation >= 1) still applies to a pre-marker instance.
|
||||
CHECK(out.lastConsumedAssignGeneration == (v >= 5 ? 4242 : 0));
|
||||
// v6 added the preview velocity; older blobs lift to the mid default.
|
||||
CHECK(out.previewVelocity == (v >= 6 ? 99 : kPreviewVelocityDefault));
|
||||
// v7 added the voice system; older blobs lift to {16, Poly, Retrigger} — the
|
||||
// pre-voice-system behavior, byte-identically.
|
||||
CHECK(out.voiceCount == (v >= 7 ? 7 : kDefaultVoiceCount));
|
||||
CHECK(out.voiceMode == (v >= 7 ? VoiceMode::Mono : VoiceMode::Poly));
|
||||
CHECK(out.monoTrigger == (v >= 7 ? MonoTrigger::Legato : MonoTrigger::Retrigger));
|
||||
// v8 added the master gain; older blobs lift to unity.
|
||||
CHECK(out.masterGainLinear == (v >= 8 ? 0.5 : 1.0));
|
||||
// v9 added the channel-mode EXPLICIT flag; older blobs lift to implicit, so the
|
||||
// auto-default may follow the loaded capture.
|
||||
CHECK(out.channelModeExplicit == (v >= 9 ? true : false));
|
||||
// v10 added the refs table (always empty here), v11 the instance guid; a pre-v11
|
||||
// blob lifts to an empty guid, which the processor mints on first publish.
|
||||
CHECK(out.instanceGuid == (v >= 11 ? "guid-abc" : ""));
|
||||
CHECK(out.sampleRefs.empty());
|
||||
}
|
||||
}
|
||||
|
||||
// A v2 blob is ZONES-ONLY — no stored selection at all — so the adopted first zone supplies
|
||||
// BOTH the capture and the parameters.
|
||||
static void testV2ZonesOnlyBlobAdoptsBothFromZoneOne() {
|
||||
legacy::Zone z;
|
||||
z.sampleId = "kick";
|
||||
z.rootOverride = 36;
|
||||
std::vector<std::uint8_t> out;
|
||||
legacy::u32v(out, kPerformanceStateVersion); // == 2, the zones-only envelope
|
||||
legacy::u32v(out, kParamsFormatMarker);
|
||||
legacy::u32v(out, 7);
|
||||
legacy::u32v(out, 1);
|
||||
legacy::putZone(out, z, 7);
|
||||
|
||||
const ComponentState st = deserializeComponentState(out, 48000.0);
|
||||
CHECK(st.selectionId == "kick");
|
||||
CHECK(st.params.rootOverride && *st.params.rootOverride == 36);
|
||||
CHECK(st.channelMode == ChannelMode::Mono); // a v2 blob predates the mode byte
|
||||
}
|
||||
|
||||
// A CORRUPT field falls back to its own DEFAULT rather than clamping to an edge the user
|
||||
// never chose (or, for the gain, silencing/blasting the instance).
|
||||
static void testCorruptFieldsFallBackToDefaults() {
|
||||
legacy::Envelope env;
|
||||
env.selectionId = "kick";
|
||||
env.previewVelocity = 0; // 0 is a note-off by convention — out of the 1..127 spec
|
||||
env.voiceCount = 200; // past kMaxVoiceCount
|
||||
env.masterGain = 1e9; // far past the +24 dB cap
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
||||
CHECK(out.previewVelocity == kPreviewVelocityDefault);
|
||||
CHECK(out.voiceCount == kDefaultVoiceCount);
|
||||
CHECK(out.masterGainLinear == 1.0);
|
||||
}
|
||||
|
||||
// CORRUPT-BLOB posture for the refs-table intrinsics: the refs table is the ONLY copy on the
|
||||
// play path, so a bad field must degrade to its own default, never poison playback. An
|
||||
// out-of-MIDI-range rootNote falls back to the middle-C default distill() uses; a negative
|
||||
// channelCount falls back to 0 = unknown (the GA auto-default then skips it). The fallback is
|
||||
// per-field — in-range neighbours pass through untouched.
|
||||
static void testSampleRefsReaderRangeFallbacks() {
|
||||
ComponentState s;
|
||||
s.sampleRefs.push_back(refEntry("hi", "b/h.wav", /*root=*/999, false, 0, 0,
|
||||
/*channels=*/-3));
|
||||
s.sampleRefs.push_back(refEntry("lo", "b/l.wav", /*root=*/-5, false, 0, 0,
|
||||
/*channels=*/1));
|
||||
s.sampleRefs.push_back(refEntry("ok", "b/o.wav", /*root=*/36, false, 0, 0,
|
||||
/*channels=*/2));
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 3);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.rootNote == 60);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[0].ref.channelCount == 0);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.rootNote == 60);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[1].ref.channelCount == 1);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.rootNote == 36);
|
||||
CHECK(back.sampleRefs.size() == 3 && back.sampleRefs[2].ref.channelCount == 2);
|
||||
}
|
||||
|
||||
// A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest of
|
||||
// the state empty (the selection/params behind the cut are unreadable anyway) — the
|
||||
// established truncation posture, never a throw across the host boundary.
|
||||
static void testSampleRefsTruncatedMidEntry() {
|
||||
ComponentState s;
|
||||
s.selectionId = "kick";
|
||||
s.sampleRefs.push_back(refEntry("kick", "b/k.wav", 36));
|
||||
s.sampleRefs.push_back(refEntry("pad", "b/p.wav", 60));
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||
// The tail after the refs table is instanceGuid(4, empty) + selectionId(4+4="kick") +
|
||||
// the current params payload for DEFAULT params (marker4+version4 + overrides3 + the
|
||||
// 91-byte play tail + keyTrack8 + curve(4+2*16, the flat 2-point default)) = 158 bytes;
|
||||
// entry two is 47 bytes (id 4+3, path 4+7, root4, loop 1+8+8, channels4, name 4+0).
|
||||
// Cutting 178 bytes keeps the first 27 of entry two's 47 — mid loop.start (offset 23..31).
|
||||
CHECK(bytes.size() > 178);
|
||||
bytes.resize(bytes.size() - 178);
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.sampleRefs.size() == 1);
|
||||
CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "kick");
|
||||
CHECK(back.selectionId.empty());
|
||||
CHECK(!back.params.rootOverride);
|
||||
}
|
||||
|
||||
// The WRITER never emits an out-of-range voice count or master gain, so a blob this codec
|
||||
// produced always re-reads as itself.
|
||||
static void testWriterClampsOutOfRangeFields() {
|
||||
ComponentState in;
|
||||
in.voiceCount = 999;
|
||||
in.masterGainLinear = 1e9;
|
||||
const ComponentState out =
|
||||
deserializeComponentState(serializeComponentState(in), 48000.0);
|
||||
CHECK(out.voiceCount >= kMinVoiceCount && out.voiceCount <= kMaxVoiceCount);
|
||||
CHECK(out.masterGainLinear <=
|
||||
reasampler::instrument::engine::masterGainMaxLinear() * (1.0 + 1e-9));
|
||||
|
||||
// Zero gain is TRUE silence and a legal stored value — it must not be "corrected".
|
||||
ComponentState silent;
|
||||
silent.masterGainLinear = 0.0;
|
||||
CHECK(deserializeComponentState(serializeComponentState(silent), 48000.0)
|
||||
.masterGainLinear == 0.0);
|
||||
}
|
||||
|
||||
// An UNKNOWN envelope version yields the empty state rather than a misparse.
|
||||
static void testUnknownEnvelopeVersionIsEmpty() {
|
||||
legacy::Envelope env;
|
||||
env.version = 99;
|
||||
env.selectionId = "kick";
|
||||
const ComponentState out =
|
||||
deserializeComponentState(legacy::envelopeWithZones(env, {}, 7), 48000.0);
|
||||
CHECK(out.selectionId.empty());
|
||||
CHECK(!out.params.rootOverride);
|
||||
}
|
||||
|
||||
// A v1 selection blob lifts to {id, default params} — the oldest live lift.
|
||||
static void testV1SelectionLift() {
|
||||
const std::vector<std::uint8_t> v1 = serializeSelection("old-pick");
|
||||
const ComponentState out = deserializeComponentState(v1, 48000.0);
|
||||
CHECK(out.selectionId == "old-pick");
|
||||
CHECK(out.map.zones.size() == 1);
|
||||
if (out.map.zones.size() == 1) {
|
||||
CHECK(out.map.zones[0].sampleId == "old-pick");
|
||||
CHECK(out.map.zones[0].lowNote == 0);
|
||||
CHECK(out.map.zones[0].highNote == 127);
|
||||
}
|
||||
CHECK(!out.params.rootOverride);
|
||||
CHECK(out.params.keyTrack == 1.0);
|
||||
}
|
||||
|
||||
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws.
|
||||
// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. Run
|
||||
// over BOTH the current format and a retired zone-list blob, since the migration path has
|
||||
// its own bounded-read walk. Beyond mere survival, a cut read must never RETAIN more refs
|
||||
// than the blob actually carried (the "keep what parsed, drop the rest" contract could not
|
||||
// silently start fabricating entries) — see testSampleRefsTruncatedMidEntry for the exact
|
||||
// mid-entry retention case this bounds only loosely across every cut point.
|
||||
static void testTruncationDegradesCleanly() {
|
||||
ComponentState in;
|
||||
in.selectionId = "smp-2";
|
||||
PerformanceZone z;
|
||||
z.sampleId = "smp-2";
|
||||
in.map.zones.push_back(z);
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(in);
|
||||
for (std::size_t cut = 0; cut < bytes.size(); ++cut) {
|
||||
const std::vector<std::uint8_t> part(bytes.begin(),
|
||||
bytes.begin() + static_cast<long>(cut));
|
||||
const ComponentState out = deserializeComponentState(part, 48000.0);
|
||||
(void)out; // reaching here without UB/throw is the contract under test
|
||||
}
|
||||
CHECK(true);
|
||||
}
|
||||
in.params.rootOverride = 61;
|
||||
in.sampleRefs.push_back(refEntry("smp-2", "b/s.wav", 61));
|
||||
in.sampleRefs.push_back(refEntry("smp-3", "b/t.wav", 62));
|
||||
const std::vector<std::uint8_t> current = serializeComponentState(in);
|
||||
|
||||
// serializePerformance/deserializePerformance round-trip through the v2 envelope.
|
||||
static void testPerformanceRoundTrip() {
|
||||
PerformanceMap in;
|
||||
PerformanceZone z;
|
||||
z.sampleId = "zone-a";
|
||||
z.lowNote = 10;
|
||||
z.highNote = 20;
|
||||
in.zones.push_back(z);
|
||||
const PerformanceMap out = deserializePerformance(serializePerformance(in), 48000.0);
|
||||
CHECK(out.zones.size() == 1);
|
||||
if (out.zones.size() == 1) {
|
||||
CHECK(out.zones[0].sampleId == "zone-a");
|
||||
CHECK(out.zones[0].lowNote == 10);
|
||||
CHECK(out.zones[0].highNote == 20);
|
||||
legacy::Zone z;
|
||||
z.sampleId = "smp-2";
|
||||
const std::vector<std::uint8_t> retired = legacy::envelopeWithZones("smp-2", {z, z}, 7);
|
||||
|
||||
for (const std::vector<std::uint8_t>* blob : {¤t, &retired}) {
|
||||
for (std::size_t cut = 0; cut < blob->size(); ++cut) {
|
||||
const std::vector<std::uint8_t> part(blob->begin(),
|
||||
blob->begin() + static_cast<long>(cut));
|
||||
const ComponentState out = deserializeComponentState(part, 48000.0);
|
||||
CHECK(out.sampleRefs.size() <= in.sampleRefs.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testComponentStateRoundTrip();
|
||||
testGoldenFullBlobFixture();
|
||||
testDefaultStateRoundTripsToDefaults();
|
||||
testEnvelopePrefixBytesFrozen();
|
||||
testWriterEmitsCurrentPayloadVersion();
|
||||
testSingleZoneMigrationIsLossless();
|
||||
testSingleZoneMigrationLiftsLoopDisablingOverride();
|
||||
testLiftedStateReSavesInCurrentFormat();
|
||||
testMultiZoneMigrationAdoptsFirstZone();
|
||||
testFirstZoneSupersedesStoredSelection();
|
||||
testEmptyZoneListKeepsTheStoredSelection();
|
||||
testEveryOlderPayloadVersionMigrates();
|
||||
testLegacyV3FramesConvertAtTheProjectRate();
|
||||
testEnvelopeLadderLiftsEachVersion();
|
||||
testV2ZonesOnlyBlobAdoptsBothFromZoneOne();
|
||||
testCorruptFieldsFallBackToDefaults();
|
||||
testSampleRefsReaderRangeFallbacks();
|
||||
testSampleRefsTruncatedMidEntry();
|
||||
testWriterClampsOutOfRangeFields();
|
||||
testUnknownEnvelopeVersionIsEmpty();
|
||||
testV1SelectionLift();
|
||||
testTruncationDegradesCleanly();
|
||||
testPerformanceRoundTrip();
|
||||
if (failures == 0) {
|
||||
std::printf("component_state_io_tests: all tests passed\n");
|
||||
return 0;
|
||||
|
||||
@@ -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
@@ -1,12 +1,11 @@
|
||||
// Standalone tests for reasampler::instrument::ui::embed_strip — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the
|
||||
// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly.
|
||||
// Same fast assert loop as the sibling pure tests: assert the embedded TCP/MCP strip's
|
||||
// layout math + key-span mapping + level fill directly.
|
||||
//
|
||||
// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area
|
||||
// (band yields to the keymap minimum, no inversion), and a zero area (all empty);
|
||||
// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly,
|
||||
// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match
|
||||
// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list;
|
||||
// keySpanRect mapping the 128-key span linearly, tiling adjacent spans seamlessly,
|
||||
// resolving a single-key span (the root marker), and clamping out-of-range/inverted notes;
|
||||
// levelFillRect clamping 0..1 and its endpoints.
|
||||
|
||||
#include "../src/core/instrument/ui/embed_strip.h"
|
||||
@@ -55,74 +54,48 @@ static void testLayoutZeroArea() {
|
||||
CHECK(N.keymap.right() >= N.keymap.x && N.keymap.bottom() >= N.keymap.y);
|
||||
}
|
||||
|
||||
// --- zoneSegmentRect ----------------------------------------------------------
|
||||
// --- keySpanRect --------------------------------------------------------------
|
||||
|
||||
static void testZoneSegmentFullSpan() {
|
||||
// A zone covering the whole keyboard spans the entire keymap band width.
|
||||
static void testKeySpanFullKeyboard() {
|
||||
// The loaded capture responds across the whole keyboard, so its span is the whole band.
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const Rect r = zoneSegmentRect(L, 0, 127);
|
||||
const Rect r = keySpanRect(L, 0, 127);
|
||||
CHECK(r.x == L.keymap.x);
|
||||
CHECK(r.right() == L.keymap.right());
|
||||
CHECK(r.y == L.keymap.y && r.bottom() == L.keymap.bottom());
|
||||
}
|
||||
|
||||
static void testAdjacentZonesTileSeamlessly() {
|
||||
// 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or
|
||||
// overlap: the low zone's right == the high zone's left.
|
||||
static void testAdjacentSpansTileSeamlessly() {
|
||||
// 256px band, 128 keys -> 2px/key. Spans 0..59 and 60..127 must abut with no gap or
|
||||
// overlap: the low span's right == the high span's left.
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const Rect lo = zoneSegmentRect(L, 0, 59);
|
||||
const Rect hi = zoneSegmentRect(L, 60, 127);
|
||||
const Rect lo = keySpanRect(L, 0, 59);
|
||||
const Rect hi = keySpanRect(L, 60, 127);
|
||||
CHECK(lo.x == L.keymap.x);
|
||||
CHECK(hi.right() == L.keymap.right());
|
||||
CHECK(lo.right() == hi.x); // seamless tile — the load-bearing assertion
|
||||
CHECK(lo.right() == L.keymap.x + 60 * 2); // 60 keys * 2px
|
||||
}
|
||||
|
||||
static void testZoneSegmentClampsBadNotes() {
|
||||
static void testSingleKeySpanIsTheRootMarker() {
|
||||
// low == high is the root marker: exactly one key wide, inside the band.
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
// Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a
|
||||
const Rect root = keySpanRect(L, 60, 60);
|
||||
CHECK(root.x == L.keymap.x + 60 * 2);
|
||||
CHECK(root.width == 2);
|
||||
CHECK(root.y == L.keymap.y && root.bottom() == L.keymap.bottom());
|
||||
}
|
||||
|
||||
static void testKeySpanClampsBadNotes() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
// Out-of-range notes clamp into the band; an inverted span (low > high) collapses to a
|
||||
// zero-or-positive-width rect, never inverts.
|
||||
const Rect over = zoneSegmentRect(L, -10, 200);
|
||||
const Rect over = keySpanRect(L, -10, 200);
|
||||
CHECK(over.x == L.keymap.x && over.right() == L.keymap.right());
|
||||
const Rect inv = zoneSegmentRect(L, 100, 20);
|
||||
const Rect inv = keySpanRect(L, 100, 20);
|
||||
CHECK(inv.right() >= inv.x);
|
||||
}
|
||||
|
||||
// --- zoneAtPoint --------------------------------------------------------------
|
||||
|
||||
static void testZoneAtPointHits() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const EmbedZone zones[2] = {{0, 59}, {60, 127}};
|
||||
// A point inside the low zone's segment resolves to zone 0; inside the high zone, 1.
|
||||
const Rect lo = zoneSegmentRect(L, 0, 59);
|
||||
const Rect hi = zoneSegmentRect(L, 60, 127);
|
||||
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
|
||||
CHECK(zoneAtPoint(L, zones, 2, lo.x + 1, yMid) == 0);
|
||||
CHECK(zoneAtPoint(L, zones, 2, hi.right() - 1, yMid) == 1);
|
||||
}
|
||||
|
||||
static void testZoneAtPointFirstMatchOnOverlap() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
// Two overlapping zones; the FIRST in order must win the contested keys.
|
||||
const EmbedZone zones[2] = {{0, 127}, {40, 80}};
|
||||
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
|
||||
const Rect contested = zoneSegmentRect(L, 40, 80);
|
||||
CHECK(zoneAtPoint(L, zones, 2, contested.x + 1, yMid) == 0); // zone 0 wins
|
||||
}
|
||||
|
||||
static void testZoneAtPointMisses() {
|
||||
const EmbedLayout L = layoutEmbed(256, 40);
|
||||
const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered
|
||||
const int yMid = (L.keymap.y + L.keymap.bottom()) / 2;
|
||||
// A key left of the zone is uncovered -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 1, L.keymap.x + 1, yMid) == -1);
|
||||
// A point in the level band (below the keymap) is off the keymap -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 1, L.levelBand.x + 4, L.levelBand.y) == -1);
|
||||
// Empty / null list -> -1.
|
||||
CHECK(zoneAtPoint(L, zones, 0, L.keymap.x + 1, yMid) == -1);
|
||||
CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.x + 1, yMid) == -1);
|
||||
}
|
||||
|
||||
// --- levelFillRect ------------------------------------------------------------
|
||||
|
||||
static void testLevelFillClamps() {
|
||||
@@ -144,12 +117,10 @@ int main() {
|
||||
testLayoutNormalArea();
|
||||
testLayoutTinyAreaKeepsKeymap();
|
||||
testLayoutZeroArea();
|
||||
testZoneSegmentFullSpan();
|
||||
testAdjacentZonesTileSeamlessly();
|
||||
testZoneSegmentClampsBadNotes();
|
||||
testZoneAtPointHits();
|
||||
testZoneAtPointFirstMatchOnOverlap();
|
||||
testZoneAtPointMisses();
|
||||
testKeySpanFullKeyboard();
|
||||
testAdjacentSpansTileSeamlessly();
|
||||
testSingleKeySpanIsTheRootMarker();
|
||||
testKeySpanClampsBadNotes();
|
||||
testLevelFillClamps();
|
||||
|
||||
if (g_fail == 0) std::printf("embed_strip: all tests passed\n");
|
||||
|
||||
@@ -116,7 +116,8 @@ static void testPresetRoundTripsThroughInstrumentReader() {
|
||||
|
||||
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
|
||||
CHECK(cs.selectionId == id); // the capture IS selected — the whole point
|
||||
CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones
|
||||
// A drop selects one capture and leaves the parameter set at its defaults.
|
||||
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
|
||||
CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default
|
||||
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign
|
||||
}
|
||||
@@ -158,7 +159,7 @@ static void testEmptyIdYieldsEmptyState() {
|
||||
CHECK(!p.compChunk.empty()); // still a versioned envelope, just an empty selection
|
||||
const ComponentState cs = deserializeComponentState(p.compChunk, kRate);
|
||||
CHECK(cs.selectionId.empty());
|
||||
CHECK(cs.map.zones.empty());
|
||||
CHECK(!cs.params.rootOverride && !cs.params.loopOverride && !cs.params.startPoint);
|
||||
}
|
||||
|
||||
// Deterministic: the same id always produces the same bytes (no time/random in the path).
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Standalone tests for reasampler::instrument::ui::keyboard_strip — no VST3, no REAPER, no framework.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the capture-first editor's
|
||||
// keyboard-strip layout, root marker, key mapping, zone-bar hit regions, and the drag-delta
|
||||
// note resolver directly — the geometry that backs the single-capture root-set and the opt-in
|
||||
// Zones panel.
|
||||
// Same fast assert loop as the sibling pure tests. Assert the editor's keyboard-strip
|
||||
// layout, root marker, key mapping, and drag-delta note resolver directly — the geometry
|
||||
// that backs the root display and root-set.
|
||||
//
|
||||
// Covers: layoutStrip (normal + zero); keyLeftX monotonic across the 128-key span with the
|
||||
// boundary at 128 == band right; keyRect / rootMarkerRect (rootMarkerRect == keyRect);
|
||||
// keyAtPoint inverting the mapping and clamping/ missing off-band; zoneBarRect spanning
|
||||
// [low,high] inclusive and collapsing (not inverting) a malformed low>high; zoneGrabAt
|
||||
// classifying low-edge / high-edge / body and the narrow-bar midpoint split (low wins the
|
||||
// tie); zoneBarAtPoint first-match on overlap + null-list rejection; resolveDragNote rounding
|
||||
// keyAtPoint inverting the mapping and clamping/ missing off-band; resolveDragNote rounding
|
||||
// to the nearest key at the key centre, clamping to [0,127], and the zero-delta / zero-width
|
||||
// no-ops; isNaturalKey across a full octave (C4..B4), at boundary notes 0 and 127, and with
|
||||
// out-of-range inputs that clamp to [0,127].
|
||||
@@ -95,69 +91,6 @@ static void testKeyAtPointOffBand() {
|
||||
CHECK(keyAtPoint(L, 100, L.keys.bottom() + 5) == -1); // below band
|
||||
}
|
||||
|
||||
// --- zoneBarRect --------------------------------------------------------------
|
||||
|
||||
static void testZoneBarSpansInclusive() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect bar = zoneBarRect(L, 12, 23); // C1..B1 inclusive
|
||||
CHECK(bar.x == keyLeftX(L, 12));
|
||||
CHECK(bar.right() == keyLeftX(L, 24)); // high+1 -> the bar covers key 23 fully
|
||||
CHECK(bar.width == 120); // 12 keys * 10px
|
||||
}
|
||||
|
||||
static void testZoneBarMalformedCollapses() {
|
||||
const StripLayout L = wideStrip();
|
||||
// low > high must collapse, never invert.
|
||||
const Rect bar = zoneBarRect(L, 80, 40);
|
||||
CHECK(bar.width >= 0);
|
||||
CHECK(bar.right() >= bar.x);
|
||||
}
|
||||
|
||||
// --- zoneGrabAt ---------------------------------------------------------------
|
||||
|
||||
static void testZoneGrabEdgesAndBody() {
|
||||
const StripLayout L = wideStrip();
|
||||
const Rect bar = zoneBarRect(L, 20, 60); // wide bar with a clear body
|
||||
const int y = L.keys.y + 2;
|
||||
// Near the left edge -> low; near the right edge -> high; the middle -> body.
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.x + 1, y) == ZoneGrab::kLowEdge);
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.right() - 1, y) == ZoneGrab::kHighEdge);
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.x + bar.width / 2, y) == ZoneGrab::kBody);
|
||||
// Off the bar entirely -> none.
|
||||
CHECK(zoneGrabAt(L, 20, 60, bar.right() + 20, y) == ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
static void testZoneGrabNarrowBarSplitsAtMidpointLowWins() {
|
||||
const StripLayout L = wideStrip();
|
||||
// A 1-key bar is narrower than 2*edge: no body; the low edge wins the exact midpoint.
|
||||
const Rect bar = zoneBarRect(L, 50, 50);
|
||||
const int y = L.keys.y + 2;
|
||||
const int mid = bar.x + bar.width / 2;
|
||||
CHECK(zoneGrabAt(L, 50, 50, mid, y) == ZoneGrab::kLowEdge); // tie -> low
|
||||
CHECK(zoneGrabAt(L, 50, 50, bar.right() - 1, y) == ZoneGrab::kHighEdge);
|
||||
}
|
||||
|
||||
// --- zoneBarAtPoint -----------------------------------------------------------
|
||||
|
||||
static void testZoneBarAtPointFirstMatch() {
|
||||
const StripLayout L = wideStrip();
|
||||
const int lows[2] = {20, 30}; // zone 0 and zone 1 overlap on [30,50]
|
||||
const int highs[2] = {50, 70};
|
||||
const Rect overlap = zoneBarRect(L, 30, 50);
|
||||
const int y = L.keys.y + 2;
|
||||
const int cx = overlap.x + overlap.width / 2;
|
||||
// A point in the overlap resolves to the FIRST covering zone (draw order).
|
||||
const ZoneBarHit hit = zoneBarAtPoint(L, lows, highs, 2, cx, y);
|
||||
CHECK(hit.zoneIndex == 0);
|
||||
CHECK(hit.grab != ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
static void testZoneBarAtPointNullList() {
|
||||
const StripLayout L = wideStrip();
|
||||
const ZoneBarHit hit = zoneBarAtPoint(L, nullptr, nullptr, 0, 100, 2);
|
||||
CHECK(hit.zoneIndex == -1 && hit.grab == ZoneGrab::kNone);
|
||||
}
|
||||
|
||||
// --- resolveDragNote ----------------------------------------------------------
|
||||
|
||||
static void testResolveDragRoundsToNearestKey() {
|
||||
@@ -246,12 +179,6 @@ int main() {
|
||||
testRootMarkerEqualsKeyRect();
|
||||
testKeyAtPointInverts();
|
||||
testKeyAtPointOffBand();
|
||||
testZoneBarSpansInclusive();
|
||||
testZoneBarMalformedCollapses();
|
||||
testZoneGrabEdgesAndBody();
|
||||
testZoneGrabNarrowBarSplitsAtMidpointLowWins();
|
||||
testZoneBarAtPointFirstMatch();
|
||||
testZoneBarAtPointNullList();
|
||||
testResolveDragRoundsToNearestKey();
|
||||
testResolveDragClampsAndNoOps();
|
||||
testResolveDragProportionalNonDivisibleWidth();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+177
-187
@@ -17,7 +17,7 @@
|
||||
// by the CMake target linking neither SDK — this file includes only sampler_core.h +
|
||||
// the standard library, which is itself the compile-time proof.
|
||||
|
||||
#include "../src/core/instrument/engine/sampler_core.h"
|
||||
#include "../src/core/instrument/engine/voice_engine.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -69,49 +69,42 @@ static AdsrParams flatAdsr() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Keymap resolution.
|
||||
// 6. Full-keyboard response over the one loaded capture.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testChromaticSingleRoot() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60));
|
||||
CHECK(km.zones.size() == 1);
|
||||
// Every note in 0..127 resolves to the single zone.
|
||||
static void testEveryKeyPlaysTheLoadedCapture() {
|
||||
// No key range survives: the loaded capture answers every note in 0..127, repitched
|
||||
// from its root. Each note-on must take a real voice.
|
||||
SampleData km = dcSample(100, 60);
|
||||
VoiceEngine eng(128, km);
|
||||
for (int n = 0; n <= 127; ++n) {
|
||||
ZoneResolution r = km.resolve(n, 100);
|
||||
CHECK(r.matched);
|
||||
CHECK(r.zoneIndex == 0);
|
||||
CHECK(eng.noteOn(n, 100) != VoiceEngine::kNoVoice);
|
||||
}
|
||||
CHECK(eng.activeVoiceCount() == 128);
|
||||
}
|
||||
|
||||
static void testZonedRangesBoundaries() {
|
||||
Keymap km;
|
||||
km.samples.push_back(dcSample(100, 48)); // low sample
|
||||
km.samples.push_back(dcSample(100, 72)); // high sample
|
||||
// Two adjacent zones: [36,59] and [60,83]. Boundary notes 59/60 must land in the
|
||||
// correct zone; a first-match order test would catch an off-by-one.
|
||||
km.zones.push_back(KeyZone{36, 59, 48, 0});
|
||||
km.zones.push_back(KeyZone{60, 83, 72, 1});
|
||||
static void testUnplayableCaptureRefusesEveryNote() {
|
||||
// Nothing decoded -> the defined no-play at every key, in both voice modes, rather
|
||||
// than a voice started on an empty read span.
|
||||
SampleData empty; // no frames
|
||||
VoiceEngine poly(4, empty);
|
||||
CHECK(poly.noteOn(60, 100) == VoiceEngine::kNoVoice);
|
||||
CHECK(poly.noteOn(0, 100) == VoiceEngine::kNoVoice);
|
||||
CHECK(poly.activeVoiceCount() == 0);
|
||||
|
||||
CHECK(km.resolve(36, 100).matched);
|
||||
CHECK(km.resolve(36, 100).zoneIndex == 0);
|
||||
CHECK(km.resolve(59, 100).zoneIndex == 0); // last note of zone 0
|
||||
CHECK(km.resolve(60, 100).zoneIndex == 1); // first note of zone 1
|
||||
CHECK(km.resolve(83, 100).zoneIndex == 1); // last note of zone 1
|
||||
|
||||
// Out of every zone -> defined no-play (not a match, not zone 0).
|
||||
CHECK(!km.resolve(35, 100).matched);
|
||||
CHECK(!km.resolve(84, 100).matched);
|
||||
CHECK(!km.resolve(127, 100).matched);
|
||||
VoiceEngine mono(4, empty, 0, 0, VoiceMode::Mono);
|
||||
CHECK(mono.noteOn(60, 100) == VoiceEngine::kNoVoice);
|
||||
CHECK(mono.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
static void testFirstMatchOnOverlap() {
|
||||
// Overlapping zones: the earlier zone wins (documented deterministic rule).
|
||||
Keymap km;
|
||||
km.samples.push_back(dcSample(10, 60));
|
||||
km.samples.push_back(dcSample(10, 60));
|
||||
km.zones.push_back(KeyZone{0, 127, 60, 0}); // catch-all first
|
||||
km.zones.push_back(KeyZone{60, 60, 60, 1}); // shadowed by the catch-all
|
||||
CHECK(km.resolve(60, 100).zoneIndex == 0);
|
||||
static void testOutOfRangeNotesAreRefusedInMono() {
|
||||
// The mono held stack keys notes as uint8, so an out-of-range note must be rejected
|
||||
// BEFORE it can alias onto a real held note.
|
||||
SampleData km = dcSample(100, 60);
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono);
|
||||
CHECK(eng.noteOn(-1, 100) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(128, 100) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -178,7 +171,7 @@ static void testRepitchObservedPeriod() {
|
||||
|
||||
// Unity: played at root, observed period ~= native.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
SampleData km = (sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -188,7 +181,7 @@ static void testRepitchObservedPeriod() {
|
||||
}
|
||||
// +1 octave: advances 2x, observed period halves.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
SampleData km = (sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(72, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -198,7 +191,7 @@ static void testRepitchObservedPeriod() {
|
||||
}
|
||||
// -1 octave: advances 0.5x, observed period doubles.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(sineSample(frames, cycles, 60));
|
||||
SampleData km = (sineSample(frames, cycles, 60));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(48, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -217,9 +210,9 @@ static void testKeyTrackVarispeedObservedPeriod() {
|
||||
auto periodAt = [&](int note, double keyTrack) -> double {
|
||||
SampleData s = sineSample(frames, cycles, 60);
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
// The single zone spans the keyboard from root 60; stamp the key-track scalar on it.
|
||||
km.zones[0].keyTrack = keyTrack;
|
||||
km.keyTrack = keyTrack;
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -247,8 +240,8 @@ static void testKeyTrackPreserveShiftCollapsesAtZero() {
|
||||
auto renderPreserve = [&](int note, double keyTrack) -> std::vector<AudioSample> {
|
||||
SampleData s = sineSample(frames, cycles, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to sample end
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].keyTrack = keyTrack;
|
||||
SampleData km = (std::move(s));
|
||||
km.keyTrack = keyTrack;
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(window));
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -375,7 +368,7 @@ static void testAdsrZeroAttackDecay() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testPolyphonicAllocation() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(1000, 60));
|
||||
SampleData km = (dcSample(1000, 60));
|
||||
VoiceEngine eng(8, km);
|
||||
|
||||
// Four simultaneous notes -> four active voices, each on a distinct voice.
|
||||
@@ -423,11 +416,11 @@ static void testNoteOffReleasesNewestSameNote() {
|
||||
SampleData sd = dcSample(100000, 60);
|
||||
sd.play.adsr = flatAdsr();
|
||||
sd.play.adsr.releaseFrames = 10; // short but non-zero so voice stays active through release
|
||||
Keymap km = Keymap::singleSampleChromatic(sd);
|
||||
SampleData km = (sd);
|
||||
// A LINEAR velocity curve keeps the two velocities distinguishable (velocity/127). The default
|
||||
// flat y=1 curve (S-VIEW-9 R10-F1) would render both at unity, collapsing the distinction this
|
||||
// note-off-selection test relies on — so we opt this zone back to the linear response.
|
||||
km.zones[0].velocityCurve = VelocityCurve::linear();
|
||||
km.velocityCurve = VelocityCurve::linear();
|
||||
VoiceEngine eng(8, km);
|
||||
|
||||
std::size_t first = eng.noteOn(60, velOld); // older voice, lower gain
|
||||
@@ -463,17 +456,6 @@ static void testNoteOffReleasesNewestSameNote() {
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
}
|
||||
|
||||
static void testOutOfZoneNoteConsumesNoVoice() {
|
||||
Keymap km;
|
||||
km.samples.push_back(dcSample(100, 60));
|
||||
km.zones.push_back(KeyZone{60, 72, 60, 0});
|
||||
VoiceEngine eng(4, km);
|
||||
|
||||
std::size_t v = eng.noteOn(30, 100); // below the only zone
|
||||
CHECK(v == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.activeVoiceCount() == 0); // no voice consumed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Voice stealing at the bound.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -484,7 +466,7 @@ static void testStealsReleasingVoiceFirst() {
|
||||
SampleData s = dcSample(100000, 60);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.releaseFrames = 100000; // long release so a released voice stays "active"
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km);
|
||||
|
||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1
|
||||
@@ -509,7 +491,7 @@ static void testStealsOldestWhenNoneReleasing() {
|
||||
SampleData s = dcSample(100000, 60);
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.releaseFrames = 100000;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km);
|
||||
|
||||
std::size_t vA = eng.noteOn(60, 100); // startOrder 1 (oldest)
|
||||
@@ -547,7 +529,7 @@ static void testLoopSustainSeamless() {
|
||||
s.loop.start = 20;
|
||||
s.loop.end = 40;
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
@@ -569,7 +551,7 @@ static void testZeroLengthLoopGoesSilent() {
|
||||
s.loop.hasLoop = true;
|
||||
s.loop.start = 25;
|
||||
s.loop.end = 25; // zero length
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
|
||||
@@ -593,7 +575,7 @@ static void testSingleFrameLoop() {
|
||||
s.loop.start = 5;
|
||||
s.loop.end = 6; // single-frame loop: [5, 6)
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
@@ -612,7 +594,7 @@ static void testAbsentLoopGoesSilent() {
|
||||
// No loop at all: held note runs off the end and goes idle (same as zero-length).
|
||||
SampleData s = dcSample(50, 60);
|
||||
// s.loop.hasLoop stays false.
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -633,7 +615,7 @@ static void testStartFrameOffsetsInitialRead() {
|
||||
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
|
||||
s.rootNote = 60;
|
||||
s.startFrame = 30;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||
std::vector<AudioSample> out;
|
||||
@@ -649,7 +631,7 @@ static void testStartFrameZeroIsUnchanged() {
|
||||
s.frames.resize(20);
|
||||
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
|
||||
s.rootNote = 60; // startFrame stays 0
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -662,7 +644,7 @@ static void testStartFrameOutOfRangeClampsToZero() {
|
||||
// out-of-bounds read that would start the voice already exhausted.
|
||||
SampleData s = dcSample(10, 60); // 10 frames of 1.0
|
||||
s.startFrame = 10; // == frameCount: out of range
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -683,7 +665,7 @@ static void testStartFrameWithLoop() {
|
||||
s.loop.hasLoop = true;
|
||||
s.loop.start = 20;
|
||||
s.loop.end = 40;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -712,7 +694,7 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
|
||||
s.loop.start = 20;
|
||||
s.loop.end = 40;
|
||||
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity
|
||||
|
||||
@@ -734,11 +716,11 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
|
||||
// velocity -> volume.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve on a KeyZone is now flat
|
||||
// S-VIEW-9 BEHAVIOR CHANGE (R10-F1 Option A): the DEFAULT velocity curve is now flat
|
||||
// y=1, so EVERY velocity plays at unity — NOT the old linear velocity/127. singleSampleChromatic
|
||||
// builds a zone with the flat default, so the DC-1 sample renders 1.0 at any velocity.
|
||||
static void testVelocityDefaultCurveIsFlatUnity() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0, flat default curve
|
||||
SampleData km = (dcSample(100, 60)); // DC 1.0, flat default curve
|
||||
for (int vel : {1, 64, 100, 127}) {
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, vel);
|
||||
@@ -751,8 +733,8 @@ static void testVelocityDefaultCurveIsFlatUnity() {
|
||||
// A LINEAR curve on the zone reproduces the pre-r10 velocity/127 ramp exactly — proving the curve
|
||||
// (not a hardcoded map) drives the gain, and that eval is applied at note-on.
|
||||
static void testVelocityLinearCurveReproducesRamp() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
||||
km.zones[0].velocityCurve = VelocityCurve::linear();
|
||||
SampleData km = (dcSample(100, 60)); // DC 1.0
|
||||
km.velocityCurve = VelocityCurve::linear();
|
||||
{
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
@@ -776,10 +758,10 @@ static void testVelocityLinearCurveReproducesRamp() {
|
||||
// A shaped curve (a single interior knot) drives the gain through eval — a mid velocity reads the
|
||||
// curve's shaped value, not the linear one. Proves the whole curve, not just the endpoints, applies.
|
||||
static void testVelocityShapedCurveDrivesGain() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
||||
SampleData km = (dcSample(100, 60)); // DC 1.0
|
||||
VelocityCurve curve = VelocityCurve::linear();
|
||||
curve.addPoint(64.0, 0.9); // pull the mid-velocity response UP to 0.9
|
||||
km.zones[0].velocityCurve = curve;
|
||||
km.velocityCurve = curve;
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 64);
|
||||
std::vector<AudioSample> out; eng.render(out, 1);
|
||||
@@ -790,7 +772,7 @@ static void testVelocityShapedCurveDrivesGain() {
|
||||
|
||||
// Two voices summed: polyphony mixes additively.
|
||||
static void testPolyphonyMixesAdditively() {
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // DC 1.0
|
||||
SampleData km = (dcSample(100, 60)); // DC 1.0
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127); // gain 1.0
|
||||
eng.noteOn(60, 127); // gain 1.0 (second voice, same note)
|
||||
@@ -826,7 +808,7 @@ static void testChannelCount() {
|
||||
static void testStereoRenderKeepsChannelsDistinct() {
|
||||
// A stereo sample (L=1.0, R=-1.0) rendered stereo must emit L and R distinctly, each
|
||||
// scaled by velocity (full here). If the engine copied L to both channels the R check fails.
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
|
||||
@@ -841,7 +823,7 @@ static void testStereoRenderKeepsChannelsDistinct() {
|
||||
static void testMonoSamplePlaysDualMonoInStereo() {
|
||||
// A MONO sample rendered through the stereo path plays dual-mono: both channels equal
|
||||
// (centered), not silent on the right. The cross-mode "mono source in stereo mode" case.
|
||||
Keymap km = Keymap::singleSampleChromatic(dcSample(100, 60)); // mono, DC 1.0
|
||||
SampleData km = (dcSample(100, 60)); // mono, DC 1.0
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> left(8, 0.f), right(8, 0.f);
|
||||
@@ -862,7 +844,7 @@ static void testDualMonoStereoSampleRendersCentered() {
|
||||
SampleData s = sineSample(600, 12.0, 60);
|
||||
s.framesR = s.frames; // dual-mono: identical channels
|
||||
s.play.pitchEngine = engine;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*preserveWindowFrames=*/128);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> left(256, 0.f), right(256, 0.f);
|
||||
@@ -884,7 +866,7 @@ static void testDualMonoStereoSampleRendersCentered() {
|
||||
static void testMonoRenderUnchangedByStereoData() {
|
||||
// Regression: the mono render path (renderFrame) reads channel 0 ONLY and is byte-identical
|
||||
// whether or not a second channel is present. A stereo sample rendered mono == its L channel.
|
||||
Keymap kmS = Keymap::singleSampleChromatic(stereoDcSample(100, 0.75f, -0.25f, 60));
|
||||
SampleData kmS = (stereoDcSample(100, 0.75f, -0.25f, 60));
|
||||
VoiceEngine engS(1, kmS);
|
||||
engS.noteOn(60, 127);
|
||||
std::vector<AudioSample> mono;
|
||||
@@ -909,7 +891,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
||||
s.framesR[i] = v;
|
||||
}
|
||||
s.rootNote = 60;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(72, 127); // +1 octave
|
||||
std::vector<AudioSample> left(frames / 2, 0.f), right(frames / 2, 0.f);
|
||||
@@ -920,7 +902,7 @@ static void testStereoRenderAdvancesLikeMonoRepitch() {
|
||||
|
||||
static void testStereoRenderSumsVoicesPerChannel() {
|
||||
// Two voices on a stereo sample sum PER CHANNEL (additive polyphony holds in stereo).
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 0.5f, -0.5f, 60));
|
||||
SampleData km = (stereoDcSample(100, 0.5f, -0.5f, 60));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(60, 127); // second voice, same note
|
||||
@@ -931,7 +913,7 @@ static void testStereoRenderSumsVoicesPerChannel() {
|
||||
}
|
||||
|
||||
static void testStereoRenderNullBufferIsNoOp() {
|
||||
Keymap km = Keymap::singleSampleChromatic(stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
SampleData km = (stereoDcSample(100, 1.0f, -1.0f, 60));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> buf(4, 0.f);
|
||||
@@ -960,7 +942,7 @@ static void testStereoStartFrameLoopShareOneReadHead() {
|
||||
s.loop.start = 20;
|
||||
s.loop.end = 30; // loop [20,30): frames 20..29
|
||||
CHECK(s.channelCount() == 2);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
|
||||
|
||||
@@ -1058,7 +1040,7 @@ static SampleData triggerSample(std::size_t frames, double lengthFraction,
|
||||
// --- Trigger %-length frame math: plays exactly round(frac*(frames-start)) frames then frees. ---
|
||||
static void testTriggerLengthFractionFrames() {
|
||||
// 200-frame sample, start 0, 50% length -> plays 100 frames then the voice frees.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||
SampleData km = (triggerSample(200, 0.5, 0, 0));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity ratio
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1072,7 +1054,7 @@ static void testTriggerLengthFractionFrames() {
|
||||
// --- Trigger start point: %-length measured from the start offset. ---
|
||||
static void testTriggerLengthWithStart() {
|
||||
// 200 frames, start 40, 50% -> span 160, play 80 frames (frames 40..119), then free.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
||||
SampleData km = (triggerSample(200, 0.5, 0, 0, /*start=*/40));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1086,7 +1068,7 @@ static void testTriggerLengthWithStart() {
|
||||
static void testTriggerFadeShape() {
|
||||
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
|
||||
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 1.0, 20, 20));
|
||||
SampleData km = (triggerSample(100, 1.0, 20, 20));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1106,7 +1088,7 @@ static void testTriggerFadeShape() {
|
||||
static void testTriggerEdgeCases() {
|
||||
// %=0: zero play length -> voice frees at once, no sound.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(100, 0.0, 5, 5));
|
||||
SampleData km = (triggerSample(100, 0.0, 5, 5));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1117,7 +1099,7 @@ static void testTriggerEdgeCases() {
|
||||
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
|
||||
{
|
||||
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(40, 1.0, 30, 30));
|
||||
SampleData km = (triggerSample(40, 1.0, 30, 30));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1127,7 +1109,7 @@ static void testTriggerEdgeCases() {
|
||||
}
|
||||
// %=100 plays the full post-start span.
|
||||
{
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(60, 1.0, 0, 0));
|
||||
SampleData km = (triggerSample(60, 1.0, 0, 0));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1139,7 +1121,7 @@ static void testTriggerEdgeCases() {
|
||||
|
||||
// --- Trigger ignores note-off (S15): the one-shot plays through regardless. ---
|
||||
static void testTriggerIgnoresNoteOff() {
|
||||
Keymap km = Keymap::singleSampleChromatic(triggerSample(200, 0.5, 0, 0));
|
||||
SampleData km = (triggerSample(200, 0.5, 0, 0));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1189,7 +1171,7 @@ static void testPreserveDurationInvariance() {
|
||||
const std::size_t window = 512; // pre-size the shifters
|
||||
|
||||
auto lengthAt = [&](int note) -> std::size_t {
|
||||
Keymap km = Keymap::singleSampleChromatic(preserveTriggerSample(frames, 1.0));
|
||||
SampleData km = (preserveTriggerSample(frames, 1.0));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/static_cast<std::int64_t>(window));
|
||||
eng.noteOn(note, 127);
|
||||
return soundingLength(eng, 4000);
|
||||
@@ -1216,7 +1198,7 @@ static void testVarispeedStillCouplesDuration() {
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
s.play.trigger.lengthFraction = 1.0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(note, 127);
|
||||
return soundingLength(eng, 4000);
|
||||
@@ -1241,7 +1223,7 @@ static void testPitchEnvOffBitIdentical() {
|
||||
s.play.pitchEnv.attackFrames = 0;
|
||||
s.play.pitchEnv.decayFrames = 500;
|
||||
}
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(67, 127); // a transposed note so ratio != 1 (exercises the ratio path)
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1269,7 +1251,7 @@ static void testPitchEnvOnBendsVarispeed() {
|
||||
s.play.pitchEnv.attackFrames = 0; // start at the peak
|
||||
s.play.pitchEnv.decayFrames = 3000; // glide to base over 3000 frames
|
||||
s.play.pitchEnv.peakSemitones = 12.0; // +1 octave at t=0
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // at root -> base ratio 1.0; the env supplies the bend
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1304,7 +1286,7 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
CHECK(s.channelCount() == 2);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 512);
|
||||
eng.noteOn(67, 127); // transposed up a fifth under Preserve (duration held)
|
||||
std::vector<AudioSample> left(2000, 0.f), right(2000, 0.f);
|
||||
@@ -1337,7 +1319,7 @@ static void testPreserveGateStereoLoopComposes() {
|
||||
static void testPreserveVoiceCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // held (Gate, no loop -> runs long enough)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
// 8 voices total, Preserve cap of 2.
|
||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 1st Preserve voice
|
||||
@@ -1362,7 +1344,7 @@ static void testPreserveUnityEngineVoiceSpeaksImmediately() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1394,7 +1376,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr(); // isolate the shifter onset from the amp attack
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/512);
|
||||
eng.noteOn(62, 127); // +2 semitones: a real shift, NOT demoted
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1413,7 +1395,7 @@ static void testPreserveTransposedVoiceSpeaksImmediately() {
|
||||
static void testPreserveUnityVoiceCountsTowardCap() {
|
||||
SampleData s = dcSample(2000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(8, km, /*preserveCap=*/2, /*window=*/256);
|
||||
CHECK(eng.noteOn(60, 127) != VoiceEngine::kNoVoice); // root: a genuine Preserve voice now
|
||||
CHECK(eng.noteOn(62, 127) != VoiceEngine::kNoVoice); // 2nd (at the cap)
|
||||
@@ -1429,8 +1411,8 @@ static void testVelocityCurveAppliesUnderPreserve() {
|
||||
auto steadyLevelAt = [&](int vel) -> double {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = VelocityCurve::linear();
|
||||
SampleData km = (std::move(s));
|
||||
km.velocityCurve = VelocityCurve::linear();
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, /*window=*/256);
|
||||
eng.noteOn(62, vel); // transposed: the genuine shifter path (not the unity demotion)
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1460,7 +1442,7 @@ static void testPerZoneAdsrReachesVoiceEnvelope() {
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
s.play.pitchEngine = PitchEngine::Varispeed; // isolate from pitch engine machinery
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127); // unity pitch, full velocity -> gain 1.0
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1483,7 +1465,7 @@ static void testZeroAdsrIsInstantSustain() {
|
||||
// Default AdsrParams{}: all zeros, sustainLevel = 1.0 (struct default). No attack ramp.
|
||||
s.play.adsr = AdsrParams{};
|
||||
s.play.pitchEngine = PitchEngine::Varispeed;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1507,17 +1489,20 @@ static SampleData dcLevelSample(std::size_t frames, float level, int rootNote) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Two-zone keymap with DISTINCT DC levels (0.25 / 0.75) so the mono tests can read which zone
|
||||
// holds the voice off the rendered value: zone A = notes [40,59] root 50 -> 0.25; zone B =
|
||||
// notes [60,80] root 70 -> 0.75.
|
||||
static Keymap twoLevelKeymap() {
|
||||
Keymap km;
|
||||
km.samples.push_back(dcLevelSample(200000, 0.25f, 50));
|
||||
km.samples.push_back(dcLevelSample(200000, 0.75f, 70));
|
||||
KeyZone a; a.lowNote = 40; a.highNote = 59; a.rootNote = 50; a.sampleIndex = 0;
|
||||
KeyZone b; b.lowNote = 60; b.highNote = 80; b.rootNote = 70; b.sampleIndex = 1;
|
||||
km.zones.push_back(a);
|
||||
km.zones.push_back(b);
|
||||
// The mono tests need to read WHICH NOTE holds the single voice off the rendered value, and
|
||||
// a DC sample makes pitch inaudible. Velocity is the discriminator: a DC 1.0 capture with a
|
||||
// curve pinned through two probe velocities renders 0.25 for a kVelLow strike and 0.75 for a
|
||||
// kVelHigh one (the Hermite spline passes exactly through its control points). Each test
|
||||
// then presses note 50 soft and note 70 hard, so the level names the sounding note.
|
||||
static constexpr int kVelLow = 32;
|
||||
static constexpr int kVelHigh = 96;
|
||||
|
||||
static SampleData twoLevelSample() {
|
||||
SampleData km = dcLevelSample(200000, 1.0f, 60);
|
||||
km.velocityCurve = VelocityCurve::fromPoints({{0.0, 0.0},
|
||||
{static_cast<double>(kVelLow), 0.25},
|
||||
{static_cast<double>(kVelHigh), 0.75},
|
||||
{127.0, 1.0}});
|
||||
return km;
|
||||
}
|
||||
|
||||
@@ -1532,11 +1517,11 @@ static double probeFrame(VoiceEngine& eng) {
|
||||
// back to the most-recent still-held note; releasing the last note gates off. Also: mono uses
|
||||
// ONE voice regardless of the pool size.
|
||||
static void testMonoLastNotePriorityAndFallback() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(50, 127) == 0); // zone A sounds
|
||||
CHECK(eng.noteOn(50, kVelLow) == 0); // zone A sounds
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
CHECK(eng.noteOn(70, 127) == 0); // zone B TAKES the voice (last-note priority)
|
||||
CHECK(eng.noteOn(70, kVelHigh) == 0); // zone B TAKES the voice (last-note priority)
|
||||
CHECK(eng.activeVoiceCount() == 1); // mono: one voice even with 4 in the pool
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70); // top released -> FALLBACK to still-held 50
|
||||
@@ -1549,10 +1534,10 @@ static void testMonoLastNotePriorityAndFallback() {
|
||||
// Releasing a LOWER held note (not the sounding one) changes nothing audible; the released
|
||||
// note also leaves the stack, so the final note-off truly empties it.
|
||||
static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127);
|
||||
eng.noteOn(70, 127); // 70 sounds, 50 held beneath
|
||||
eng.noteOn(50, kVelLow);
|
||||
eng.noteOn(70, kVelHigh); // 70 sounds, 50 held beneath
|
||||
eng.noteOff(50); // releasing the buried note: inaudible
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70); // 50 already left the stack -> silence, no fallback
|
||||
@@ -1562,11 +1547,11 @@ static void testMonoReleaseOfLowerHeldNoteIsInaudible() {
|
||||
// Re-pressing a HELD note moves it to the top of the stack (it sounds again), and the note
|
||||
// beneath becomes the fallback.
|
||||
static void testMonoRepressHeldNoteMovesToTop() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127);
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(eng.noteOn(50, 127) == 0); // re-press while held: back on top
|
||||
eng.noteOn(50, kVelLow);
|
||||
eng.noteOn(70, kVelHigh);
|
||||
CHECK(eng.noteOn(50, kVelLow) == 0); // re-press while held: back on top
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
eng.noteOff(50); // falls back to 70 (now the most recent held)
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
@@ -1578,8 +1563,8 @@ static void testMonoRepressHeldNoteMovesToTop() {
|
||||
// held note on the stack), not the departing note's.
|
||||
static void testMonoRetriggerFallbackUsesOriginalVelocity() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = VelocityCurve::linear(); // gain = velocity/127
|
||||
SampleData km = (std::move(s));
|
||||
km.velocityCurve = VelocityCurve::linear(); // gain = velocity/127
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(60, 32); // soft first note
|
||||
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||
@@ -1589,16 +1574,17 @@ static void testMonoRetriggerFallbackUsesOriginalVelocity() {
|
||||
CHECK(approx(probeFrame(eng), 32.0 / 127.0, 1e-4));
|
||||
}
|
||||
|
||||
// An OUT-OF-ZONE note in mono is a defined no-play: it consumes nothing, never joins the
|
||||
// stack (so it can never take the voice back on a fallback), and its note-off is inert.
|
||||
static void testMonoOutOfZoneNeverJoinsStack() {
|
||||
Keymap km = twoLevelKeymap(); // zones cover [40,59] + [60,80] only
|
||||
// An OUT-OF-RANGE note in mono is a defined no-play: it consumes nothing, never joins the
|
||||
// stack (so it can never take the voice back on a fallback), and its note-off is inert. The
|
||||
// stack keys notes as uint8, so an unguarded 200 would alias onto 72 and corrupt it.
|
||||
static void testMonoOutOfRangeNeverJoinsStack() {
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(70, 127);
|
||||
CHECK(eng.noteOn(20, 127) == VoiceEngine::kNoVoice); // out of every zone
|
||||
eng.noteOn(70, kVelHigh);
|
||||
CHECK(eng.noteOn(200, 127) == VoiceEngine::kNoVoice); // past the MIDI range
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6)); // 70 undisturbed
|
||||
eng.noteOff(20); // inert
|
||||
eng.noteOff(200); // inert
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
@@ -1609,7 +1595,7 @@ static void testMonoOutOfZoneNeverJoinsStack() {
|
||||
static void testMonoRetriggerRestartsEnvelope() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100; // slow linear attack: level at frame i = i/100
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1623,7 +1609,7 @@ static void testMonoRetriggerRestartsEnvelope() {
|
||||
static void testMonoLegatoContinuesEnvelope() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1645,8 +1631,8 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
|
||||
}
|
||||
s.rootNote = 60;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
km.zones[0].velocityCurve = VelocityCurve::linear();
|
||||
SampleData km = (std::move(s));
|
||||
km.velocityCurve = VelocityCurve::linear();
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // unity: read advances 1/frame, full gain
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1657,16 +1643,21 @@ static void testMonoLegatoRetunesWithoutReadRestart() {
|
||||
CHECK(approx(probeFrame(eng), 12.0, 1e-3)); // and now advances at ratio 2 (the new pitch)
|
||||
}
|
||||
|
||||
// LEGATO applies only to a SAME-SAMPLE takeover: crossing into a zone playing a DIFFERENT
|
||||
// sample restarts the voice (one read head cannot glide between two PCM streams).
|
||||
static void testMonoLegatoCrossSampleRestarts() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
km.samples[1].play.adsr.attackFrames = 100; // zone B has a slow attack to expose a restart
|
||||
// LEGATO takeover ALWAYS glides now: with one loaded capture there is no second PCM stream
|
||||
// to cross into, so the read head never has to restart mid-phrase. (The retired
|
||||
// cross-sample-restart branch was the multi-zone case.)
|
||||
static void testMonoLegatoAlwaysGlidesWithinThePhrase() {
|
||||
SampleData km = twoLevelSample();
|
||||
km.play.adsr.attackFrames = 100; // a slow attack would expose any restart
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(50, 127); // zone A (flat env): 0.25 at once
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
eng.noteOn(70, 127); // cross-sample: RESTART (attack from 0), no retune
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-6)); // zone B's fresh attack origin — not 0.25 held over
|
||||
eng.noteOn(50, kVelLow);
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50); // mid-attack: level ~0.49 * the 0.25 vel gain
|
||||
CHECK(approx(out[49], 0.49 * 0.25, 1e-6));
|
||||
eng.noteOn(70, kVelHigh); // takeover: envelope KEEPS running, no re-attack
|
||||
// Frame 50 of the SAME attack ramp, still at the FIRST strike's velocity gain (a legato
|
||||
// phrase is one gesture, one strike) — NOT 0.0 (a restart) and NOT 0.75 (a re-strike).
|
||||
CHECK(approx(probeFrame(eng), 0.50 * 0.25, 1e-6));
|
||||
}
|
||||
|
||||
// LEGATO after the last note was RELEASED re-attacks: a releasing voice's note has left the
|
||||
@@ -1675,7 +1666,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.releaseFrames = 1000; // long release keeps the voice audibly ringing
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1692,7 +1683,7 @@ static void testMonoLegatoAfterReleaseReattacks() {
|
||||
static void testMonoIgnoresPreserveCap() {
|
||||
SampleData s = dcSample(4000, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(4, km, /*preserveCap=*/1, /*window=*/256,
|
||||
VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(62, 127) == 0); // 1st Preserve note: at the cap
|
||||
@@ -1719,7 +1710,7 @@ static SampleData rampSample(std::size_t frames, int rootNote) {
|
||||
static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger; // default TriggerParams: full length, no fades
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // unity: read advances 1/frame
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1739,7 +1730,7 @@ static void testMonoLegatoTriggerReattacksAfterKeyUp() {
|
||||
static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1751,7 +1742,7 @@ static void testMonoLegatoTriggerHeldKeyStillRetunes() {
|
||||
// MAJOR-2: allNotesOff releases every gated poly voice (flat release -> instant silence).
|
||||
static void testAllNotesOffReleasesPolyVoices() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127);
|
||||
@@ -1765,16 +1756,16 @@ static void testAllNotesOffReleasesPolyVoices() {
|
||||
// MAJOR-2, the STUCK-NOTE path: allNotesOff clears the mono held stack, so a phantom entry
|
||||
// (simulating a LOST note-off) can never be resurrected by the fallback afterwards.
|
||||
static void testAllNotesOffClearsMonoHeldStack() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
eng.noteOn(50, 127); // 50's note-off will never arrive (phantom)
|
||||
eng.noteOn(70, 127); // 70 sounds, phantom 50 buried on the stack
|
||||
eng.noteOn(50, kVelLow); // 50's note-off will never arrive (phantom)
|
||||
eng.noteOn(70, kVelHigh); // 70 sounds, phantom 50 buried on the stack
|
||||
eng.allNotesOff(); // PANIC
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
CHECK(eng.activeVoiceCount() == 0);
|
||||
// The stack is empty: a fresh press + release gates off cleanly, with NO fallback
|
||||
// restart of the phantom (pre-fix, noteOff(70) here re-struck 50 -> 0.25 forever).
|
||||
eng.noteOn(70, 127);
|
||||
eng.noteOn(70, kVelHigh);
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70);
|
||||
CHECK(approx(probeFrame(eng), 0.0, 1e-9));
|
||||
@@ -1790,7 +1781,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 1.0; // full length — would ring for 200000 frames
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km);
|
||||
eng.noteOn(60, 127);
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
@@ -1812,7 +1803,7 @@ static void testAllSoundsOffStopsTriggerOneShot() {
|
||||
static void testAllNotesOffStillReleasesGateVoices() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
// Default Gate mode, instant release (releaseFrames 0).
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(4, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127);
|
||||
@@ -1829,7 +1820,7 @@ static void testAllNotesOffStillReleasesGateVoices() {
|
||||
// rather than retune. This is the correct fresh-phrase behavior documented in the comment.
|
||||
static void testMonoLegatoSameNoteRepressReattacks() {
|
||||
SampleData s = rampSample(200000, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Legato);
|
||||
eng.noteOn(60, 127); // first press; read starts at 0
|
||||
std::vector<AudioSample> out;
|
||||
@@ -1845,7 +1836,7 @@ static void testMonoLegatoSameNoteRepressReattacks() {
|
||||
// losing its fallback. Note-ons out of [0,127] are a defined no-play.
|
||||
static void testMonoOutOfRangeNotesRejected() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(128, 127) == VoiceEngine::kNoVoice);
|
||||
CHECK(eng.noteOn(-1, 127) == VoiceEngine::kNoVoice);
|
||||
@@ -1865,7 +1856,7 @@ static void testMonoOutOfRangeNotesRejected() {
|
||||
// notes and steals (never grows) on the N+1th; 0 clamps to the documented 1-voice degenerate.
|
||||
static void testVoiceCountBoundsPolyphony() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine e3(3, km);
|
||||
CHECK(e3.maxVoices() == 3);
|
||||
e3.noteOn(60, 127);
|
||||
@@ -1894,7 +1885,7 @@ static void testMonoRetrigTakeoverDeclicksRestart() {
|
||||
s.play.adsr.attackFrames = 100; // real attack: the new tone starts near 0
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -1930,7 +1921,7 @@ static void testMonoRetrigFallbackDeclicksRestart() {
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -1965,7 +1956,7 @@ static void testMonoDeclickOnlyOnTakeover() {
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -1993,7 +1984,7 @@ static void testPolyStealDeclicksRestart() {
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2026,7 +2017,7 @@ static void testSameBlockDoubleTakeoverKeepsDeclickSeed() {
|
||||
s.play.adsr.attackFrames = 100;
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2062,7 +2053,7 @@ static void testZeroAttackTakeoverNeverExceedsFullScale() {
|
||||
s.play.adsr.attackFrames = 0; // zero-attack: amp == 1 on the very first frame
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2107,7 +2098,7 @@ static double maxDeltaAcross(double lastPre, const std::vector<AudioSample>& pos
|
||||
static void testMonoRetrigTriggerZoneDeclicksRestart() {
|
||||
SampleData s = sineSample(48000, 100.0, 60); // period 480 frames; slope <= ~0.013/frame
|
||||
s.play.playMode = PlayMode::Trigger; // default fades: NO fade-in -> amp 1 at frame 0
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2134,7 +2125,7 @@ static void testZeroAttackGateRetrigNoStep() {
|
||||
s.play.adsr.attackFrames = 0; // instant-unity attack
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2158,7 +2149,7 @@ static void testZeroAttackGateRetrigNoStep() {
|
||||
// preview's exact shape (same note, root, full pool of 1).
|
||||
static void testPreviewReauditionDeclicksViaEngineSteal() {
|
||||
SampleData s = sineSample(48000, 100.0, 60); // default ADSR: instant unity (worst case)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Poly, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2186,7 +2177,7 @@ static void testOverCapChordStealsExactlyOne() {
|
||||
s.play.adsr.sustainLevel = 1.0;
|
||||
s.play.adsr.releaseFrames = 2880; // 60 ms @ 48k
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
// Mirrors the processor: kPreserveVoiceCap = 8, 50 ms OLA window at 48k = 2400 frames.
|
||||
VoiceEngine eng(3, km, /*preserveVoiceCap=*/8, /*preserveWindowFrames=*/2400);
|
||||
|
||||
@@ -2238,7 +2229,7 @@ static void testOverCapChordStealsExactlyOne() {
|
||||
// path. This is the processor's mailbox-drain contract, pinned in the pure core.
|
||||
static void testPreviewNoteObeysVoicing() {
|
||||
SampleData s = dcLevelSample(200000, 1.0f, 60);
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(2, km);
|
||||
eng.noteOn(60, 127);
|
||||
eng.noteOn(62, 127); // the pool is now FULL
|
||||
@@ -2260,11 +2251,11 @@ static void testPreviewNoteObeysVoicing() {
|
||||
// Pins the processor's mailbox-drain contract for Mono the way testPreviewNoteObeysVoicing
|
||||
// pins it for Poly steal.
|
||||
static void testPreviewNoteJoinsMonoHeldStack() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine eng(4, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger);
|
||||
CHECK(eng.noteOn(50, 127) == 0); // the host-MIDI note: zone A sounds
|
||||
CHECK(eng.noteOn(50, kVelLow) == 0); // the host-MIDI note: zone A sounds
|
||||
CHECK(approx(probeFrame(eng), 0.25, 1e-6));
|
||||
CHECK(eng.noteOn(70, 127) == 0); // the preview press: TAKES the voice
|
||||
CHECK(eng.noteOn(70, kVelHigh) == 0); // the preview press: TAKES the voice
|
||||
CHECK(eng.activeVoiceCount() == 1); // still mono — the preview is no side-car
|
||||
CHECK(approx(probeFrame(eng), 0.75, 1e-6));
|
||||
eng.noteOff(70); // preview release: FALLBACK to the held note
|
||||
@@ -2281,10 +2272,10 @@ static void testPreviewNoteJoinsMonoHeldStack() {
|
||||
// the drain engine must release its voice (otherwise the old-snapshot preview would
|
||||
// sustain until the next reload hard-cut it).
|
||||
static void testPreviewNoteOffRoutesToDrainEngine() {
|
||||
Keymap km = twoLevelKeymap();
|
||||
SampleData km = twoLevelSample();
|
||||
VoiceEngine drainEng(2, km); // was live when the preview fired
|
||||
VoiceEngine liveEng(2, km); // the post-reload fresh snapshot: no voices
|
||||
CHECK(drainEng.noteOn(70, 127) != VoiceEngine::kNoVoice);
|
||||
CHECK(drainEng.noteOn(70, kVelHigh) != VoiceEngine::kNoVoice);
|
||||
CHECK(approx(probeFrame(drainEng), 0.75, 1e-6)); // the preview rings in the old snapshot
|
||||
CHECK(liveEng.activeVoiceCount() == 0);
|
||||
// The preview release, drained to BOTH engines like a host note-off:
|
||||
@@ -2313,7 +2304,7 @@ static void testDeclickBoundedBlendNoOvershoot() {
|
||||
const double kCycles = 6000.0; // period = 8 frames
|
||||
SampleData s = sineSample(kFrames, kCycles, 60);
|
||||
s.play.playMode = PlayMode::Trigger; // no fade-in -> amp 1 on frame 0 (worst case)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, 0, 0, VoiceMode::Mono, MonoTrigger::Retrigger,
|
||||
/*takeoverDeclick=*/true);
|
||||
|
||||
@@ -2402,7 +2393,7 @@ static void testPreserveTailFinalWindowGapFree() {
|
||||
SampleData s = tailSine(frames, f0, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve; // Gate, no loop -> runs to the sample end
|
||||
s.play.adsr = flatAdsr(); // held: amp 1 to the end (isolates the DSP)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(note, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -2430,7 +2421,7 @@ static void testPreserveTailReleaseContinuous() {
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
s.play.adsr.releaseFrames = static_cast<std::int64_t>(w); // release spans the final window
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(67, 127);
|
||||
std::vector<AudioSample> out;
|
||||
@@ -2462,7 +2453,7 @@ static void testPreserveTriggerTailGapFree() {
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 0.8; // playEnd = 6554 (~40 exact cycles: ends near zero)
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(67, 127);
|
||||
const std::size_t playEnd = 6554; // round(0.8 * 8192)
|
||||
@@ -2497,7 +2488,7 @@ static void testPreservePrimeStopsAtTriggerPlayEnd() {
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.trigger.lengthFraction = 0.0625; // exactly 500 / 8000
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(72, 127); // +1 octave: the tap outruns the read head into
|
||||
// the deepest primed history the ring holds
|
||||
@@ -2525,7 +2516,7 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
|
||||
SampleData s = tailSine(frames, f0, 60);
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr = flatAdsr();
|
||||
Keymap km = Keymap::singleSampleChromatic(std::move(s));
|
||||
SampleData km = (std::move(s));
|
||||
VoiceEngine eng(1, km, /*preserveCap=*/0, static_cast<std::int64_t>(w));
|
||||
eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring)
|
||||
std::vector<AudioSample> out;
|
||||
@@ -2538,9 +2529,9 @@ static void testPreserveSubWindowSampleNoZeroPadInRing() {
|
||||
}
|
||||
|
||||
int main() {
|
||||
testChromaticSingleRoot();
|
||||
testZonedRangesBoundaries();
|
||||
testFirstMatchOnOverlap();
|
||||
testEveryKeyPlaysTheLoadedCapture();
|
||||
testUnplayableCaptureRefusesEveryNote();
|
||||
testOutOfRangeNotesAreRefusedInMono();
|
||||
testPitchRatioMath();
|
||||
testKeyTrackedRatioMath();
|
||||
testRepitchObservedPeriod();
|
||||
@@ -2551,7 +2542,6 @@ int main() {
|
||||
testAdsrZeroAttackDecay();
|
||||
testPolyphonicAllocation();
|
||||
testNoteOffReleasesNewestSameNote();
|
||||
testOutOfZoneNoteConsumesNoVoice();
|
||||
testStealsReleasingVoiceFirst();
|
||||
testStealsOldestWhenNoneReleasing();
|
||||
testLoopSustainSeamless();
|
||||
@@ -2611,11 +2601,11 @@ int main() {
|
||||
testMonoReleaseOfLowerHeldNoteIsInaudible();
|
||||
testMonoRepressHeldNoteMovesToTop();
|
||||
testMonoRetriggerFallbackUsesOriginalVelocity();
|
||||
testMonoOutOfZoneNeverJoinsStack();
|
||||
testMonoOutOfRangeNeverJoinsStack();
|
||||
testMonoRetriggerRestartsEnvelope();
|
||||
testMonoLegatoContinuesEnvelope();
|
||||
testMonoLegatoRetunesWithoutReadRestart();
|
||||
testMonoLegatoCrossSampleRestarts();
|
||||
testMonoLegatoAlwaysGlidesWithinThePhrase();
|
||||
testMonoLegatoAfterReleaseReattacks();
|
||||
testMonoIgnoresPreserveCap();
|
||||
testMonoLegatoTriggerReattacksAfterKeyUp();
|
||||
|
||||
Reference in New Issue
Block a user