diff --git a/CMakeLists.txt b/CMakeLists.txt index c057d46..d37baef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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; diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index e2d6411..3ae371f 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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 diff --git a/src/core/instrument/engine/envelopes.h b/src/core/instrument/engine/envelopes.h new file mode 100644 index 0000000..219ea37 --- /dev/null +++ b/src/core/instrument/engine/envelopes.h @@ -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 +#include + +#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(framesInStage_) / + static_cast(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(framesInStage_) / + static_cast(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(framesInStage_) / + static_cast(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(playLength_)) { + // At/past the play length the one-shot is done; the voice also frees on + // readPos >= playEnd. + if (sourceOffset >= static_cast(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(playLength_ - fadeOut_); + if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { + const double phase = sourceOffset / static_cast(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(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(pos_) / static_cast(a)); + } else if (pos_ < a + d) { + // Decay: peak -> 0 over decayFrames (settle to base pitch). + const double t = static_cast(pos_ - a) / static_cast(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 diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/play_params.h similarity index 73% rename from src/core/instrument/engine/zone_params.h rename to src/core/instrument/engine/play_params.h index 6e3f257..f447ad7 100644 --- a/src/core/instrument/engine/zone_params.h +++ b/src/core/instrument/engine/play_params.h @@ -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 #include #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 frames; std::vector 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 diff --git a/src/core/instrument/engine/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp deleted file mode 100644 index 3903341..0000000 --- a/src/core/instrument/engine/sampler_core.cpp +++ /dev/null @@ -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 - -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(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(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(framesInStage_) / - static_cast(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(framesInStage_) / - static_cast(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(framesInStage_) / - static_cast(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(playLength_)) { - // At/past the play length the one-shot is done; the voice also frees on readPos >= playEnd. - if (sourceOffset >= static_cast(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(playLength_ - fadeOut_); - if (fadeIn_ > 0 && sourceOffset < static_cast(fadeIn_)) { - const double phase = sourceOffset / static_cast(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(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(pos_) / static_cast(a)); - } else if (pos_ < a + d) { - // Decay: peak -> 0 over decayFrames (settle to base pitch). - const double t = static_cast(pos_ - a) / static_cast(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(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(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(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(sample.frames.size()); - std::int64_t start = sample.startFrame; - if (start < 0 || start >= frameCount) start = 0; - readPos_ = static_cast(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( - static_cast(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(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& 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(i)] = - (q < frameCount) ? pcmCh[static_cast(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(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& pcm = sample_->frames; - const std::int64_t frameCount = static_cast(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& 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(loop.end - loop.start); - while (readPos_ >= static_cast(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(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(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(r); - return static_cast(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(feedPos_)] : 0.0f; - const double shift = baseRatio_ * envFactor; - shiftL_.setShiftRatio(shift); - const double shiftedL = static_cast(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(feedPos_)] : 0.0f; - shiftR_.setShiftRatio(shift); - outRlocal = - static_cast(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(readPos_); - const double frac = readPos_ - static_cast(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(pcm[i0]) : 0.0) + - ((i1ok ? static_cast(pcm[i1]) : 0.0) - - (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; - outL = srcL * gain; - if (stereo) { - const double srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + - ((i1ok ? static_cast(pcmR[i1]) : 0.0) - - (i0ok ? static_cast(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(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(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(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(note), - static_cast(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& 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 diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h deleted file mode 100644 index eb0955e..0000000 --- a/src/core/instrument/engine/sampler_core.h +++ /dev/null @@ -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 -#include -#include -#include - -#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 samples; - std::vector 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 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& 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(-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 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 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 diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp new file mode 100644 index 0000000..18560af --- /dev/null +++ b/src/core/instrument/engine/voice.cpp @@ -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 + +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(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(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(sample.frames.size()); + std::int64_t start = sample.startFrame; + if (start < 0 || start >= frameCount) start = 0; + readPos_ = static_cast(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( + static_cast(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(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& 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(i)] = + (q < frameCount) ? pcmCh[static_cast(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 diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h new file mode 100644 index 0000000..c8e857e --- /dev/null +++ b/src/core/instrument/engine/voice.h @@ -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 +#include +#include + +#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(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(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(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(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& pcm = sample_->frames; + const std::int64_t frameCount = static_cast(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& 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(loop.end - loop.start); + while (readPos_ >= static_cast(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(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(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(r); + return static_cast(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(feedPos_)] : 0.0f; + const double shift = baseRatio_ * envFactor; + shiftL_.setShiftRatio(shift); + const double shiftedL = static_cast(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(feedPos_)] : 0.0f; + shiftR_.setShiftRatio(shift); + outRlocal = + static_cast(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(readPos_); + const double frac = readPos_ - static_cast(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(pcm[i0]) : 0.0) + + ((i1ok ? static_cast(pcm[i1]) : 0.0) - + (i0ok ? static_cast(pcm[i0]) : 0.0)) * frac; + outL = srcL * gain; + if (stereo) { + const double srcR = (i0ok ? static_cast(pcmR[i0]) : 0.0) + + ((i1ok ? static_cast(pcmR[i1]) : 0.0) - + (i0ok ? static_cast(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(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(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 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 diff --git a/src/core/instrument/engine/voice_engine.cpp b/src/core/instrument/engine/voice_engine.cpp new file mode 100644 index 0000000..34e16fb --- /dev/null +++ b/src/core/instrument/engine/voice_engine.cpp @@ -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(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(note), + static_cast(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& 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 diff --git a/src/core/instrument/engine/voice_engine.h b/src/core/instrument/engine/voice_engine.h new file mode 100644 index 0000000..1629ead --- /dev/null +++ b/src/core/instrument/engine/voice_engine.h @@ -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 +#include +#include +#include + +#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& 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(-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 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 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 diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp index 0fb93de..7f2eaf9 100644 --- a/src/core/instrument/map/component_state_io.cpp +++ b/src/core/instrument/map/component_state_io.cpp @@ -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 // 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(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& out, const PerformanceMap& map) { - putLE(out, kZonesFormatMarker); - putLE(out, kZonesPayloadVersion); - putLE(out, static_cast(map.zones.size())); - for (const PerformanceZone& z : map.zones) { - putLE(out, static_cast(z.sampleId.size())); - out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); - putLE(out, static_cast(static_cast(z.lowNote))); - putLE(out, static_cast(static_cast(z.highNote))); - out.push_back(z.rootOverride ? 1 : 0); - if (z.rootOverride) { - putLE(out, - static_cast(static_cast(*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& pts = z.velocityCurve.points(); - putLE(out, static_cast(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& out, const InstrumentParams& p) { + out.push_back(p.rootOverride ? 1 : 0); + if (p.rootOverride) { + putLE(out, static_cast(static_cast(*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& 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& pts = p.velocityCurve.points(); + putLE(out, static_cast(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 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(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(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(r.i64()) / liftRate; - z.play.pitchEnv.decaySeconds = static_cast(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(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(r.i64()) / liftRate; + p.play.pitchEnv.decaySeconds = static_cast(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 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(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 serializePerformance(const PerformanceMap& map) { - std::vector out; - putLE(out, kPerformanceStateVersion); - putZonesPayload(out, map); return out; } -PerformanceMap deserializePerformance(const std::vector& 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 serializeComponentState(const ComponentState& state) { @@ -268,7 +279,7 @@ std::vector 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(state.sampleRefs.size())); @@ -286,75 +297,65 @@ std::vector serializeComponentState(const ComponentState& state) { putLE(out, static_cast(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(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(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& 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& 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& 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& 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& 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& 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& 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& 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; } diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index 104a611..80cad43 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 #include #include -#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 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& 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& 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_" ext-state +// kSelectionRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes — the +// minted per-instance identity the usage publisher keys its "rsusage_" 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_" // 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 serializeComponentState(const ComponentState& state); @@ -266,16 +233,13 @@ ComponentState deserializeComponentState(const std::vector& 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 serializeSelection(const std::string& sampleId); // too-short, or empty -> "" (graceful no-selection). std::string deserializeSelection(const std::vector& bytes); - } // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/note_entry.cpp b/src/core/instrument/map/note_entry.cpp deleted file mode 100644 index f2119a7..0000000 --- a/src/core/instrument/map/note_entry.cpp +++ /dev/null @@ -1,111 +0,0 @@ -// note_entry.cpp — see note_entry.h. - -#include "core/instrument/map/note_entry.h" - -#include -#include - -namespace reasampler::instrument::map { - -namespace { -char asciiUpper(char c) { - return static_cast(std::toupper(static_cast(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(s[a]))) ++a; - while (b > a && std::isspace(static_cast(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(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 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(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(octave + 1) * 12 + semitone; - return clampNote(note); -} - -std::optional 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(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 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(s[0])) || s[0] == '+' || - (s[0] == '-' && s.size() > 1 && std::isdigit(static_cast(s[1])))) { - if (auto n = parseInteger(s)) return n; - } - return parseNoteName(s); -} - -} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/note_entry.h b/src/core/instrument/map/note_entry.h deleted file mode 100644 index 3ecb4d4..0000000 --- a/src/core/instrument/map/note_entry.h +++ /dev/null @@ -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 -#include - -namespace reasampler::instrument::map { - -// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt. -std::optional parseNoteEntry(const std::string& text); - -} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 6206b1b..0e034c6 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -3,7 +3,7 @@ #include "core/instrument/map/sample_map.h" -#include // std::min +#include // std::remove_if #include // assert #include // 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 selectSample(const std::string& banksJson, @@ -90,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI return nullptr; } -std::vector referencedSampleIds(const std::string& selectionId, - const PerformanceMap& map) { +std::vector referencedSampleIds(const std::string& selectionId) { std::vector 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 extractChannel(const std::vector& interlea return out; } -DecodedZonePcm decodeChannels(const std::vector& interleaved, - int sourceChannels, ChannelMode mode, int sampleRate) { +DecodedPcm decodeChannels(const std::vector& 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& 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(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 frames, int sampleRate, - int rootNote, const SampleLoop& loop, - std::vector 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 resolveFromBank(const std::string& banksJson, + const std::string& selectionId, + const InstrumentParams& params) { + const std::optional sel = selectSample(banksJson, selectionId); + if (!sel) return std::nullopt; + return resolveCapture(*sel, params); +} + +std::optional 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 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& zones, - const std::vector& 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 diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 63edeab..3020949 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -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 #include @@ -12,7 +13,7 @@ #include #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; // 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 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 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 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 downmixToMono(const std::vector& interleaved, int channelCount); @@ -136,14 +134,14 @@ std::vector downmixToMono(const std::vector& interleav std::vector extractChannel(const std::vector& 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 frames, int sampleRate, - int rootNote, const SampleLoop& loop, - std::vector 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 rootOverride; // instrument-owned override; absent -> bank intrinsic - std::optional loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic + std::optional loopOverride; // instrument-owned sustain loop; absent -> intrinsic std::optional 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 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 zones; - std::vector 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 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 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 monoFrames; // channel 0 (mono, or L of a stereo decode) int sampleRate = 0; // 0 is explicitly invalid std::vector framesR; // channel 1 (R); EMPTY for a mono decode }; -Keymap buildZonedKeymap(const std::vector& zones, - const std::vector& 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& interleaved, - int sourceChannels, ChannelMode mode, int sampleRate); +// Empty/zero-channel input -> empty frames (caller plays silence). +DecodedPcm decodeChannels(const std::vector& 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. diff --git a/src/core/instrument/ui/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp index 9c68c05..f6cdba0 100644 --- a/src/core/instrument/ui/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -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 #include diff --git a/src/core/instrument/ui/curve_popup.h b/src/core/instrument/ui/curve_popup.h index 3eea28d..f9fb146 100644 --- a/src/core/instrument/ui/curve_popup.h +++ b/src/core/instrument/ui/curve_popup.h @@ -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 diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp deleted file mode 100644 index f1342c6..0000000 --- a/src/core/instrument/ui/editor_geometry.cpp +++ /dev/null @@ -1,273 +0,0 @@ -// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types. - -#include "core/instrument/ui/editor_geometry.h" - -#include - -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 diff --git a/src/core/instrument/ui/editor_geometry.h b/src/core/instrument/ui/editor_geometry.h index 4ec5f3e..08f5cdb 100644 --- a/src/core/instrument/ui/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -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 diff --git a/src/core/instrument/ui/embed_strip.cpp b/src/core/instrument/ui/embed_strip.cpp index dec0dcd..ce77524 100644 --- a/src/core/instrument/ui/embed_strip.cpp +++ b/src/core/instrument/ui/embed_strip.cpp @@ -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{}; diff --git a/src/core/instrument/ui/embed_strip.h b/src/core/instrument/ui/embed_strip.h index 2caee05..1e0edf8 100644 --- a/src/core/instrument/ui/embed_strip.h +++ b/src/core/instrument/ui/embed_strip.h @@ -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. diff --git a/src/core/instrument/ui/envelope_edit.h b/src/core/instrument/ui/envelope_edit.h index 8437fcc..55a48a7 100644 --- a/src/core/instrument/ui/envelope_edit.h +++ b/src/core/instrument/ui/envelope_edit.h @@ -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 diff --git a/src/core/instrument/ui/envelope_overlay.h b/src/core/instrument/ui/envelope_overlay.h index d65f9e2..0b88197 100644 --- a/src/core/instrument/ui/envelope_overlay.h +++ b/src/core/instrument/ui/envelope_overlay.h @@ -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 diff --git a/src/core/instrument/ui/keyboard_strip.cpp b/src/core/instrument/ui/keyboard_strip.cpp index 558217b..96494b2 100644 --- a/src/core/instrument/ui/keyboard_strip.cpp +++ b/src/core/instrument/ui/keyboard_strip.cpp @@ -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] = { diff --git a/src/core/instrument/ui/keyboard_strip.h b/src/core/instrument/ui/keyboard_strip.h index 87533ad..f09d15e 100644 --- a/src/core/instrument/ui/keyboard_strip.h +++ b/src/core/instrument/ui/keyboard_strip.h @@ -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 diff --git a/src/core/instrument/ui/sample_bands.cpp b/src/core/instrument/ui/sample_bands.cpp new file mode 100644 index 0000000..f0b54c4 --- /dev/null +++ b/src/core/instrument/ui/sample_bands.cpp @@ -0,0 +1,54 @@ +// sample_bands.cpp — see sample_bands.h. Pure math; no host types. + +#include "core/instrument/ui/sample_bands.h" + +#include + +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 diff --git a/src/core/instrument/ui/sample_bands.h b/src/core/instrument/ui/sample_bands.h new file mode 100644 index 0000000..6049d75 --- /dev/null +++ b/src/core/instrument/ui/sample_bands.h @@ -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 diff --git a/src/core/instrument/ui/sample_chrome.cpp b/src/core/instrument/ui/sample_chrome.cpp new file mode 100644 index 0000000..aec545f --- /dev/null +++ b/src/core/instrument/ui/sample_chrome.cpp @@ -0,0 +1,71 @@ +// sample_chrome.cpp — see sample_chrome.h. Pure math; no host types. + +#include "core/instrument/ui/sample_chrome.h" + +#include + +#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 diff --git a/src/core/instrument/ui/sample_chrome.h b/src/core/instrument/ui/sample_chrome.h new file mode 100644 index 0000000..27dd977 --- /dev/null +++ b/src/core/instrument/ui/sample_chrome.h @@ -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 diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index 3cebce0..d6fe147 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -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_` 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_` 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 diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp index a23d2a7..2b5d5e5 100644 --- a/src/shell/instrument/editor_controls.cpp +++ b/src/shell/instrument/editor_controls.cpp @@ -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(processor_->previewVelocity()) / 127.0; } -std::vector 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 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 out; { DeckGroupDesc amp; @@ -179,14 +194,6 @@ std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe static_cast(ParamControl::kPitchEnvDepth)}; out.push_back(std::move(penv)); } - return out; -} - -std::vector 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 out = zoneDeckGroupDescs(play); { DeckGroupDesc voice; voice.id = kGroupVoice; @@ -206,22 +213,22 @@ std::vector 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(id)) { case ParamControl::kKeyTrack: - return clamp01(zone.keyTrack / kKeyTrackMax); + return clamp01(params_.keyTrack / kKeyTrackMax); case ParamControl::kVoiceCount: return clamp01(static_cast(voiceCount_ - kMinVoiceCount) / static_cast(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(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(map_.zones.size())) { - return map_.zones[static_cast(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(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(map_.zones.size())) return; - PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; +void ReaSamplerEditor::applyParamControl(int id, double value, int segment) { if (id == static_cast(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 diff --git a/src/shell/instrument/editor_input.cpp b/src/shell/instrument/editor_input.cpp new file mode 100644 index 0000000..b439f93 --- /dev/null +++ b/src/shell/instrument/editor_input.cpp @@ -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_ 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(ParamControl::kVoiceCount) || + paramId == static_cast(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(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(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 diff --git a/src/shell/instrument/editor_input_browse.cpp b/src/shell/instrument/editor_input_browse.cpp new file mode 100644 index 0000000..ac4ab0e --- /dev/null +++ b/src/shell/instrument/editor_input_browse.cpp @@ -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 +#include + +#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(banks_.size()) + 1; + const int tab = filterTabHitTest(bl, tabCount, bx, by); + if (tab >= 0) { + activeFilterBankId_ = (tab == 0) ? std::string() + : banks_[static_cast(tab - 1)].id; + rebuildVisible(); + invalidate(); + return; + } + const Rect thumb = scrollThumbRect(bl, static_cast(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(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(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(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(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(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(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 diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp deleted file mode 100644 index 7ee41cd..0000000 --- a/src/shell/instrument/editor_input_browse_zone.cpp +++ /dev/null @@ -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 -#include -#include -#include - -#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(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - const int card = (tab >= 0) - ? -1 - : cardHitTest(bl, static_cast(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(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(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 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(banks_.size()) + 1; - const int tab = filterTabHitTest(bl, tabCount, bx, by); - if (tab >= 0) { - activeFilterBankId_ = (tab == 0) ? std::string() - : banks_[static_cast(tab - 1)].id; - rebuildVisible(); - invalidate(); - return; - } - const Rect thumb = scrollThumbRect(bl, static_cast(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(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(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(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(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(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 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(map_.zones.size()), lx, ly); - if (hit.zoneIndex >= 0) { - selectedZone_ = hit.zoneIndex; - const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { - const int note = keyAtPoint(sl, lx, ly); - if (note >= 0) { - map_.zones[static_cast(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(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(map_.zones.size())) { - if (contains(zonesCurveButton(content), x, y)) { - curvePopupOpen_ = true; - invalidate(); - return; - } - const ZonePlaySeconds& play = map_.zones[static_cast(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(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(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(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(map_.zones.size())) { - PerformanceZone& z = map_.zones[static_cast(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(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(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 diff --git a/src/shell/instrument/editor_input_chrome.cpp b/src/shell/instrument/editor_input_chrome.cpp new file mode 100644 index 0000000..5a78b4a --- /dev/null +++ b/src/shell/instrument/editor_input_chrome.cpp @@ -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 diff --git a/src/shell/instrument/editor_input_curve.cpp b/src/shell/instrument/editor_input_curve.cpp new file mode 100644 index 0000000..c6d4d7e --- /dev/null +++ b/src/shell/instrument/editor_input_curve.cpp @@ -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(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(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(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(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 diff --git a/src/shell/instrument/editor_input_deck.cpp b/src/shell/instrument/editor_input_deck.cpp new file mode 100644 index 0000000..5fa1bc8 --- /dev/null +++ b/src/shell/instrument/editor_input_deck.cpp @@ -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(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(ParamControl::kPitchEnvAttack) || + hit.id == static_cast(ParamControl::kPitchEnvDecay) || + hit.id == static_cast(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 diff --git a/src/shell/instrument/editor_input_sample.cpp b/src/shell/instrument/editor_input_sample.cpp deleted file mode 100644 index a973372..0000000 --- a/src/shell/instrument/editor_input_sample.cpp +++ /dev/null @@ -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 -#include -#include -#include - -#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(map_.zones.size())) return; - const VelocityCurve::Box box = curveBoxFromRect(r); - if (box.width <= 0 || box.height <= 1) return; - PerformanceZone& z = map_.zones[static_cast(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(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(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 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(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(ParamControl::kPitchEnvAttack) || - hit.id == static_cast(ParamControl::kPitchEnvDecay) || - hit.id == static_cast(ParamControl::kPitchEnvDepth); - if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return; - if (hit.id == static_cast(ParamControl::kVoiceCount) || - hit.id == static_cast(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(zi)]); - } - dragStartX_ = x; - dragStartY_ = y; - invalidate(); - } - return; - } - - // Hero waveform: envelope nodes first, then the wave markers. - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(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(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(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 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(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(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(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(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(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(map_.zones.size())) return; - if (curvePointIndex_ < 0) return; - const int dy = y - dragStartY_; - map_.zones[static_cast(dragCurveZone_)].velocityCurve = - VelocityCurve::resolvePointDrag(dragStartCurve_, - static_cast(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(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& pcm = monoPcmFor(selectedId_); - if (!pcm.empty()) { - newFrame = nearestZeroCrossing(pcm.data(), static_cast(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(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(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(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(ParamControl::kVoiceCount) || - paramId == static_cast(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(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(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(curveZone)].velocityCurve.deletePoint( - static_cast(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(zi)]; - if (z.velocityCurve.deletePoint(static_cast(idx))) { - selectedZone_ = zi; - hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node - commitAndReload(); - } -} - -} // namespace reasampler::vst - -#endif // _WIN32 diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp new file mode 100644 index 0000000..1918dc1 --- /dev/null +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -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 +#include +#include + +#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& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(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(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(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(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(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& pcm = monoPcmFor(selectedId_); + if (!pcm.empty()) { + newFrame = nearestZeroCrossing(pcm.data(), static_cast(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 diff --git a/src/shell/instrument/editor_internal.h b/src/shell/instrument/editor_internal.h index c3ea21d..94c1314 100644 --- a/src/shell/instrument/editor_internal.h +++ b/src/shell/instrument/editor_internal.h @@ -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; diff --git a/src/shell/instrument/editor_paint.cpp b/src/shell/instrument/editor_paint.cpp new file mode 100644 index 0000000..08cce9a --- /dev/null +++ b/src/shell/instrument/editor_paint.cpp @@ -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 +#include + +#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 diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse.cpp similarity index 52% rename from src/shell/instrument/editor_paint_browse_zone.cpp rename to src/shell/instrument/editor_paint_browse.cpp index 309eb05..1cf414e 100644 --- a/src/shell/instrument/editor_paint_browse_zone.cpp +++ b/src/shell/instrument/editor_paint_browse.cpp @@ -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 #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(map_.zones.size()); ++i) { - const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { - const PerformanceZone& z = map_.zones[static_cast(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 diff --git a/src/shell/instrument/editor_paint_chrome.cpp b/src/shell/instrument/editor_paint_chrome.cpp new file mode 100644 index 0000000..6be09c8 --- /dev/null +++ b/src/shell/instrument/editor_paint_chrome.cpp @@ -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 +#include + +#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(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 diff --git a/src/shell/instrument/editor_paint_curve.cpp b/src/shell/instrument/editor_paint_curve.cpp new file mode 100644 index 0000000..a1b7244 --- /dev/null +++ b/src/shell/instrument/editor_paint_curve.cpp @@ -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(i)); + const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(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 diff --git a/src/shell/instrument/editor_paint_deck.cpp b/src/shell/instrument/editor_paint_deck.cpp new file mode 100644 index 0000000..b9f90c8 --- /dev/null +++ b/src/shell/instrument/editor_paint_deck.cpp @@ -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 +#include + +#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(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(c.id))); + kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim); + } + } +} + +} // namespace reasampler::vst + +#endif // _WIN32 diff --git a/src/shell/instrument/editor_paint_sample.cpp b/src/shell/instrument/editor_paint_sample.cpp deleted file mode 100644 index ced83e8..0000000 --- a/src/shell/instrument/editor_paint_sample.cpp +++ /dev/null @@ -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 -#include -#include -#include -#include - -#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 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& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(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::max)(1, waveformColumnCount(toKitBox(waveArea)))) * - kWaveformOversample; - const std::size_t bins = - static_cast(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(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(frames) / rate; - const std::int64_t startFrame = zone.startPoint.value_or(0); - const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); - const std::vector 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(i)); - const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast(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& 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(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(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 diff --git a/src/shell/instrument/editor_paint_waveform.cpp b/src/shell/instrument/editor_paint_waveform.cpp new file mode 100644 index 0000000..7b48fc0 --- /dev/null +++ b/src/shell/instrument/editor_paint_waveform.cpp @@ -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 +#include +#include + +#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& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(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::max)(1, waveformColumnCount(toKitBox(lane)))) * + kWaveformOversample; + const std::size_t bins = + static_cast(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(frames) / rate; + const std::int64_t startFrame = params_.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame); + const std::vector 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 diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index fa30067..8693969 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -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(ParamControl::kVoiceCount) || self->dragParamId_ == static_cast(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(); } } diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 6fea63c..2f32519 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -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{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); - const auto prevZoneCount = static_cast(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(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(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 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(map_.zones.size()); ++i) { - PerformanceZone& z = map_.zones[static_cast(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(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(map_.zones.size()); ++i) { - if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - map_.zones.push_back(z); - return static_cast(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& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp index 5130173..c34a1b3 100644 --- a/src/shell/instrument/processor_reload.cpp +++ b/src/shell/instrument/processor_reload.cpp @@ -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 decodeRelative(const std::string& projectDir, - const std::string& relativePath, - ChannelMode mode) { +// resolve/read/decode failure — the caller plays silence. +std::optional 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 bytes = readFileBytes(abs); @@ -72,8 +71,8 @@ std::optional decodeRelative(const std::string& projectDir, if (!layout.valid) return std::nullopt; std::vector interleaved = extractFloatFrames(bytes, layout, 0, layout.frameCount()); - DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode, - static_cast(layout.sampleRate)); + DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode, + static_cast(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 ids = referencedSampleIds(selId, map); + const InstrumentParams params = instrumentParams(); + const std::vector ids = referencedSampleIds(selId); SampleRefs refs; { std::optional 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 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 decoded; - std::vector kept; - decoded.reserve(resolved.zones.size()); - kept.reserve(resolved.zones.size()); - for (const ResolvedZone& rz : resolved.zones) { - std::optional 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 cm(channelModeMutex_); + channelMode_ = channelModeFor(sel->channelCount, channelMode_, + channelModeExplicit_); + mode = channelMode_; + } + std::optional 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 cm(channelModeMutex_); - channelMode_ = channelModeFor(sel->channelCount, channelMode_, - channelModeExplicit_); - mode = channelMode_; - } - std::optional 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( - std::move(km), static_cast(builtVoiceCount), gen, + std::move(sample), static_cast(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 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 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( - std::move(km), static_cast(builtVoiceCount), gen, + std::move(sample), static_cast(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) { diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index ab45a2d..1d7ea10 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -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 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 lock(usageMutex_); @@ -147,14 +142,14 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { selectedSampleId_ = id; } -PerformanceMap ReaSamplerProcessor::performanceMap() { - std::lock_guard lock(performanceMutex_); - return performanceMap_; +InstrumentParams ReaSamplerProcessor::instrumentParams() { + std::lock_guard lock(paramsMutex_); + return params_; } -void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { - std::lock_guard lock(performanceMutex_); - performanceMap_ = map; +void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) { + std::lock_guard lock(paramsMutex_); + params_ = params; } SampleRefs ReaSamplerProcessor::sampleRefs() { diff --git a/src/shell/instrument/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h index 73b2416..ef7360b 100644 --- a/src/shell/instrument/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -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 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& 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& 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 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 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 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 samples_; // every bank sample, bank order std::vector banks_; // the named banks, for the filter tab strip std::vector 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 diff --git a/src/shell/instrument/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp index 7b7a1b6..4381a8e 100644 --- a/src/shell/instrument/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -4,6 +4,7 @@ #include "shell/instrument/reasampler_embed.h" +#include #include #include @@ -62,15 +63,6 @@ std::string sampleLabel(const std::vector& 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 toEmbedZones(const PerformanceMap& map) { - std::vector 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{}; - map_ = processor_->performanceMap(); - if (selectedZone_ >= static_cast(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(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(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(drawInfo); - if (!di || di->width <= 0 || di->height <= 0) return false; - refresh(); - const EmbedLayout layout = layoutEmbed(di->width, di->height); - const std::vector zones = toEmbedZones(map_); - const int hit = zoneAtPoint(layout, zones.data(), static_cast(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 diff --git a/src/shell/instrument/reasampler_embed.h b/src/shell/instrument/reasampler_embed.h index 8e7342d..c653b02 100644 --- a/src/shell/instrument/reasampler_embed.h +++ b/src/shell/instrument/reasampler_embed.h @@ -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 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 diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index d5bb9da..568f166 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -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(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> 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 diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp index fdb1441..6c47ea1 100644 --- a/tests/test_browser_scroll.cpp +++ b/tests/test_browser_scroll.cpp @@ -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 #include diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 0b4a618..76d086d 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -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 +#include #include #include @@ -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& out, std::uint8_t v) { out.push_back(v); } + +static void u32v(std::vector& out, std::uint32_t v) { + for (int i = 0; i < 4; ++i) out.push_back(static_cast((v >> (8 * i)) & 0xFF)); +} + +static void i64v(std::vector& out, std::int64_t v) { + const auto u = static_cast(v); + for (int i = 0; i < 8; ++i) out.push_back(static_cast((u >> (8 * i)) & 0xFF)); +} + +static void f64v(std::vector& 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((bits >> (8 * i)) & 0xFF)); +} + +static void strv(std::vector& out, const std::string& s) { + u32v(out, static_cast(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 curve; // empty -> the flat endpoints +}; + +static void putZone(std::vector& out, const Zone& z, std::uint32_t pv) { + strv(out, z.sampleId); + u32v(out, static_cast(z.lowNote)); + u32v(out, static_cast(z.highNote)); + u8v(out, z.rootOverride >= 0 ? 1 : 0); + if (z.rootOverride >= 0) u32v(out, static_cast(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 pts = + z.curve.empty() ? std::vector{{0.0, 1.0}, {127.0, 1.0}} : z.curve; + u32v(out, static_cast(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(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 envelopeWithZones(const Envelope& env, + const std::vector& zones, + std::uint32_t pv) { + std::vector 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(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 envelopeWithZones(const std::string& selectionId, + const std::vector& 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 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 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 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 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(bytes[i]) | + (static_cast(bytes[i + 1]) << 8) | + (static_cast(bytes[i + 2]) << 16) | + (static_cast(bytes[i + 3]) << 24); + if (m != kParamsFormatMarker) continue; + const std::uint32_t v = static_cast(bytes[i + 4]) | + (static_cast(bytes[i + 5]) << 8) | + (static_cast(bytes[i + 6]) << 16) | + (static_cast(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 out; + legacy::u32v(out, kComponentStateVersion); + legacy::u8v(out, 0); + legacy::i64v(out, 0); + legacy::u8v(out, kPreviewVelocityDefault); + legacy::u8v(out, static_cast(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 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 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 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 bytes = serializeComponentState(in); - for (std::size_t cut = 0; cut < bytes.size(); ++cut) { - const std::vector part(bytes.begin(), - bytes.begin() + static_cast(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 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 retired = legacy::envelopeWithZones("smp-2", {z, z}, 7); + + for (const std::vector* blob : {¤t, &retired}) { + for (std::size_t cut = 0; cut < blob->size(); ++cut) { + const std::vector part(blob->begin(), + blob->begin() + static_cast(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; diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp deleted file mode 100644 index 8a4eef2..0000000 --- a/tests/test_editor_geometry.cpp +++ /dev/null @@ -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 - -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; -} diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp index 9d3ea93..586c49b 100644 --- a/tests/test_embed_strip.cpp +++ b/tests/test_embed_strip.cpp @@ -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"); diff --git a/tests/test_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 2a6a934..b6357c3 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -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). diff --git a/tests/test_keyboard_strip.cpp b/tests/test_keyboard_strip.cpp index 61bbc4b..69a4647 100644 --- a/tests/test_keyboard_strip.cpp +++ b/tests/test_keyboard_strip.cpp @@ -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(); diff --git a/tests/test_note_entry.cpp b/tests/test_note_entry.cpp deleted file mode 100644 index 1107f36..0000000 --- a/tests/test_note_entry.cpp +++ /dev/null @@ -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 - -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; -} diff --git a/tests/test_sample_bands.cpp b/tests/test_sample_bands.cpp new file mode 100644 index 0000000..881f8c9 --- /dev/null +++ b/tests/test_sample_bands.cpp @@ -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 + +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; +} diff --git a/tests/test_sample_chrome.cpp b/tests/test_sample_chrome.cpp new file mode 100644 index 0000000..47f0bdf --- /dev/null +++ b/tests/test_sample_chrome.cpp @@ -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 + +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; +} diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index d0f68c2..10761ef 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -1,29 +1,31 @@ // Standalone tests for reasampler::sample_map — no VST3, no REAPER, no test framework. -// Same fast assert loop as the sibling pure tests. This module is the S4 mapping heart: -// bank blob -> selected sample (through the SHARED bank_book JSON parse), interleaved -> -// mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap build, and the -// selected-sample instance-state (de)serialization. +// Same fast assert loop as the sibling pure tests. This module is the mapping heart: bank +// blob -> selected capture (through the SHARED bank_book JSON parse), the channel policy, +// the one parameter set's override-beats-intrinsic fold, and the SampleData build. +// +// The ComponentState wire ladder lives in test_component_state_io.cpp — its own module, its +// own suite, since the Q-W2v split. // // Every assertion is written to FAIL if the mapping were wrong: the bank blobs are built // by serializing a real BankBook (so we exercise the shared parse, not a fixture string), -// and the selection / downmix / keymap / state values are checked against independently +// and the selection / downmix / resolve / build values are checked against independently // computed expectations. // -// Covers: selectSample by-id hit (across pool + named banks), the S10 policy reversal -// (empty / stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> -// nullopt, zero-samples -> nullopt, rootNote/loop intrinsic threading incl. the middle-C -// default; listSamples ordinal order + the card metadata (rootNote/key/bankId) + empty/ -// malformed; listBanks ordinal order (pool first) + empty/malformed; downmixToMono mono -// passthrough / stereo average / 3-ch average / zero-stride / empty; buildTier0Keymap single -// full-keyboard zone with the root + loop + rate threaded and rate defaulting; selection -// state round-trip + empty id + wrong-version / truncated -> ""; component state (v3) -// round-trip + v1/v2 back-compat lift + empty/unknown -> empty. -// wav_trim -> extractFloatFrames -> downmixToMono integration: locks the interleave- -// stride contract across the seam (that the byte stride wav_trim reports matches the -// channel-count stride downmixToMono divides by). +// Covers: selectSample by-id hit (across pool + named banks), the policy reversal (empty / +// stale id -> SILENCE nullopt, not the first sample), empty & malformed blob -> nullopt, +// zero-samples -> nullopt, rootNote/loop/channel-count intrinsic threading incl. the +// middle-C default; channelModeFor's auto-default rule; listSamples ordinal order + the +// card metadata + empty/malformed; listBanks ordinal order (pool first); downmixToMono / +// extractChannel / decodeChannels across both channel modes; the instance-owned refs +// helpers (findRef / referencedSampleIds / refreshRefsFromBank / retainRefs) and the +// legacy-lift decision; resolvePlay's seconds->frames conversion at the live rate; +// resolveCapture's override-beats-intrinsic fold and the bank/refs paths' agreement; +// buildSampleData's threading, per-decode rate resolution, and channel handling; the +// selection-state round-trip; and the wav_trim -> extractFloatFrames -> downmixToMono +// integration, which locks the interleave-stride contract across that seam. #include "../src/core/instrument/map/sample_map.h" -#include "../src/core/instrument/map/component_state_io.h" // the Q-W2v codec split (formats FROZEN; suite unchanged) +#include "../src/core/instrument/map/component_state_io.h" // serializeSelection (the v1 blob) #include #include @@ -33,11 +35,10 @@ #include "../src/core/model/bank_book.h" #include "../src/core/model/bank_model.h" -#include "../src/core/instrument/engine/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap) using namespace reasampler; using namespace reasampler::instrument::engine; -using namespace reasampler::instrument::map; // sample_map + component_state_io (Q-W2v re-namespace) +using namespace reasampler::instrument::map; using namespace reasampler::capture; // wav_trim (WavLayout) using namespace reasampler::model; @@ -290,30 +291,6 @@ static void testDownmixDegenerate() { CHECK(downmixToMono({0.1f, 0.2f}, -1).empty()); // negative stride } -// --- buildTier0Keymap --------------------------------------------------------- - -static void testBuildKeymapSingleFullZone() { - SampleLoop loop; - loop.hasLoop = true; - loop.start = 10; - loop.end = 90; - const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 40, loop); - // One sample, one zone spanning the whole keyboard, rooted at 40. - CHECK(km.samples.size() == 1); - CHECK(km.zones.size() == 1); - CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 0 && km.zones[0].highNote == 127); - CHECK(km.zones.size() == 1 && km.zones[0].rootNote == 40); - CHECK(km.samples.size() == 1 && km.samples[0].sampleRate == 48000); - CHECK(km.samples.size() == 1 && km.samples[0].rootNote == 40); - CHECK(km.samples.size() == 1 && km.samples[0].frames.size() == 3); - CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && - km.samples[0].loop.start == 10 && km.samples[0].loop.end == 90); - // Resolution: any note lands in the single zone. - CHECK(km.resolve(0, 100).matched); - CHECK(km.resolve(127, 100).matched); -} - - // --- selection state (setState/getState) -------------------------------------- static void testSelectionStateRoundTrip() { @@ -440,410 +417,6 @@ static void testWavTrimToDownmixPipelineMono() { CHECK(approx(mono[0], 0.0) && approx(mono[1], 0.5) && approx(mono[2], 1.0)); } -// --- performance map: resolvePerformance -------------------------------------- - -static PerformanceZone zone(const std::string& id, int lo, int hi, - std::optional rootOverride = std::nullopt) { - PerformanceZone z; - z.sampleId = id; - z.lowNote = lo; - z.highNote = hi; - z.rootOverride = rootOverride; - return z; -} - -static void testResolveEmptyMap() { - // An empty performance map resolves to nothing (the shell falls back to Tier 0). - const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); - const ResolvedPerformance r = resolvePerformance(json, PerformanceMap{}); - CHECK(r.zones.empty()); - CHECK(r.droppedSampleIds.empty()); -} - -static void testResolveEmptyBlob() { - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - CHECK(resolvePerformance("", m).zones.empty()); // no bank - CHECK(resolvePerformance("{garbage", m).zones.empty()); // malformed -} - -static void testResolveMultiZoneAcrossBanks() { - const std::string json = bookJson( - {makeSample("a", "Kick", "b/a.wav", 36)}, - {makeSample("b", "Snare", "b/b.wav", 38)}); - PerformanceMap m; - m.zones.push_back(zone("a", 36, 47)); - m.zones.push_back(zone("b", 48, 59)); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 2); - CHECK(r.droppedSampleIds.empty()); - // Order preserved; paths + ranges threaded. - CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav"); - CHECK(r.zones.size() == 2 && r.zones[0].lowNote == 36 && r.zones[0].highNote == 47); - CHECK(r.zones.size() == 2 && r.zones[1].relativePath == "b/b.wav"); - CHECK(r.zones.size() == 2 && r.zones[1].lowNote == 48 && r.zones[1].highNote == 59); -} - -static void testResolveStaleIdDropsZone() { - // STALE-ID POLICY: a zone naming a deleted sample is dropped, its id reported; the - // surviving zone still resolves (the whole map is NOT abandoned). - const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); - PerformanceMap m; - m.zones.push_back(zone("a", 0, 59)); - m.zones.push_back(zone("ghost", 60, 127)); // no such sample - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1); - CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav"); - CHECK(r.droppedSampleIds.size() == 1); - CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost"); -} - -static void testResolveRootPrecedence() { - // Override beats bank intrinsic beats middle-C default. - const std::string json = bookJson( - {makeSample("rooted", "R", "b/r.wav", 40), // bank intrinsic 40 - makeSample("unrooted", "U", "b/u.wav", std::nullopt)}, // no intrinsic - {}); - PerformanceMap m; - m.zones.push_back(zone("rooted", 0, 42)); // no override -> 40 - m.zones.push_back(zone("rooted", 43, 84, /*override=*/72)); // override -> 72 - m.zones.push_back(zone("unrooted", 85, 127)); // no intrinsic -> 60 - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 3); - CHECK(r.zones.size() == 3 && r.zones[0].rootNote == 40); // bank intrinsic - CHECK(r.zones.size() == 3 && r.zones[1].rootNote == 72); // override wins - CHECK(r.zones.size() == 3 && r.zones[2].rootNote == 60); // middle-C default -} - -static void testResolveLoopThreaded() { - Sample s = makeSample("a", "Pad", "b/a.wav", 60); - s.loop = LoopPoints{200, 800}; - const std::string json = bookJson({s}, {}); - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1); - CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); - CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); - // No loop override + no startPoint -> effective start is 0 (S11 default). - CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0); -} - -static void testResolveLoopOverrideWins() { - // S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the - // startPoint feeds the effective startFrame — without mutating the bank. - Sample s = makeSample("a", "Pad", "b/a.wav", 60); - s.loop = LoopPoints{200, 800}; // bank intrinsic - const std::string json = bookJson({s}, {}); - PerformanceMap m; - PerformanceZone z = zone("a", 0, 127); - SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000; - z.loopOverride = over; // instrument override - z.startPoint = 512; // start offset - m.zones.push_back(z); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); - CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000); - CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512); -} - -static void testResolveLoopOverrideDisablesLoop() { - // A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance - // (override present-but-empty wins over the intrinsic — a deliberate "no loop here"). - Sample s = makeSample("a", "Pad", "b/a.wav", 60); - s.loop = LoopPoints{200, 800}; - const std::string json = bookJson({s}, {}); - PerformanceMap m; - PerformanceZone z = zone("a", 0, 127); - z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0 - m.zones.push_back(z); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop); -} - -// --- performance map: buildZonedKeymap ---------------------------------------- - -static void testBuildZonedKeymapMultiZone() { - std::vector zones; - ResolvedZone z0; z0.lowNote = 36; z0.highNote = 47; z0.rootNote = 36; zones.push_back(z0); - ResolvedZone z1; z1.lowNote = 48; z1.highNote = 59; z1.rootNote = 48; zones.push_back(z1); - std::vector decoded; - decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100}); - decoded.push_back(DecodedZonePcm{{0.3f, 0.4f, 0.5f}, 48000}); - const Keymap km = buildZonedKeymap(zones, decoded); - CHECK(km.samples.size() == 2); - CHECK(km.zones.size() == 2); - // Zone 0 -> sample 0, rooted 36, range 36..47; zone 1 -> sample 1, rooted 48. - CHECK(km.zones.size() == 2 && km.zones[0].sampleIndex == 0 && km.zones[0].rootNote == 36); - CHECK(km.zones.size() == 2 && km.zones[0].lowNote == 36 && km.zones[0].highNote == 47); - CHECK(km.zones.size() == 2 && km.zones[1].sampleIndex == 1 && km.zones[1].rootNote == 48); - CHECK(km.samples.size() == 2 && km.samples[1].sampleRate == 48000); - CHECK(km.samples.size() == 2 && km.samples[1].frames.size() == 3); - // Resolution: a note in each range lands in the right zone. - CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0); - CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1); - // A note outside every zone does not match (no-play, not zone 0). - CHECK(!km.resolve(24, 100).matched); -} - -static void testBuildZonedKeymapThreadsLoopAndStart() { - // S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the - // voice honors them at note-on. - std::vector zones; - ResolvedZone z0; - z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; - z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7; - z0.startFrame = 2; - zones.push_back(z0); - std::vector decoded; - decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100}); - const Keymap km = buildZonedKeymap(zones, decoded); - CHECK(km.samples.size() == 1); - CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop && - km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7); - CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2); -} - -static void testBuildZonedKeymapDropsEmptyPcm() { - // A zone whose decoded WAV is empty is dropped; the other zone survives, and the - // survivor's sampleIndex points at ITS sample (not the dropped one's slot). - std::vector zones; - ResolvedZone z0; z0.lowNote = 0; z0.highNote = 63; z0.rootNote = 60; zones.push_back(z0); - ResolvedZone z1; z1.lowNote = 64; z1.highNote = 127; z1.rootNote = 72; zones.push_back(z1); - std::vector decoded; - decoded.push_back(DecodedZonePcm{{}, 44100}); // empty -> dropped - decoded.push_back(DecodedZonePcm{{0.9f}, 44100}); // survives - const Keymap km = buildZonedKeymap(zones, decoded); - CHECK(km.samples.size() == 1); - CHECK(km.zones.size() == 1); - CHECK(km.zones.size() == 1 && km.zones[0].sampleIndex == 0); // remapped to slot 0 - CHECK(km.zones.size() == 1 && km.zones[0].lowNote == 64 && km.zones[0].rootNote == 72); -} - -static void testBuildZonedKeymapOverlapFirstWins() { - // OVERLAP POLICY: two zones share keys; the FIRST in order wins the contested note - // (mirrors the S3 core's first-match resolve). - std::vector zones; - ResolvedZone z0; z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60; zones.push_back(z0); - ResolvedZone z1; z1.lowNote = 60; z1.highNote = 72; z1.rootNote = 48; zones.push_back(z1); - std::vector decoded; - decoded.push_back(DecodedZonePcm{{0.1f}, 44100}); - decoded.push_back(DecodedZonePcm{{0.2f}, 44100}); - const Keymap km = buildZonedKeymap(zones, decoded); - CHECK(km.zones.size() == 2); - // Note 64 is in both zones; first-match resolves to zone 0. - CHECK(km.resolve(64, 100).matched && km.resolve(64, 100).zoneIndex == 0); -} - -static void testBuildZonedKeymapEmpty() { - // No zones -> empty keymap (silence). - const Keymap km = buildZonedKeymap({}, {}); - CHECK(km.samples.empty() && km.zones.empty()); - CHECK(!km.resolve(60, 100).matched); -} - -// --- performance-map state: serialize / deserialize --------------------------- - -static void testPerformanceStateRoundTrip() { - PerformanceMap m; - m.zones.push_back(zone("kick", 36, 47)); // no override - m.zones.push_back(zone("snare", 48, 59, /*override=*/50)); // with override - const std::vector bytes = serializePerformance(m); - const PerformanceMap back = deserializePerformance(bytes, 44100.0); - CHECK(back.zones.size() == 2); - CHECK(back.zones.size() == 2 && back.zones[0].sampleId == "kick"); - CHECK(back.zones.size() == 2 && back.zones[0].lowNote == 36 && back.zones[0].highNote == 47); - CHECK(back.zones.size() == 2 && !back.zones[0].rootOverride.has_value()); - CHECK(back.zones.size() == 2 && back.zones[1].sampleId == "snare"); - CHECK(back.zones.size() == 2 && back.zones[1].rootOverride.has_value() && - *back.zones[1].rootOverride == 50); -} - -static void testPerformanceStateEmpty() { - const std::vector bytes = serializePerformance(PerformanceMap{}); - // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4). - CHECK(bytes.size() == 16); - CHECK(deserializePerformance(bytes, 44100.0).zones.empty()); -} - -static void testPerformanceStateLoopStartRoundTrip() { - // S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip. - PerformanceMap m; - PerformanceZone z = zone("pad", 24, 96, /*override=*/64); - SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890; - z.loopOverride = lp; - z.startPoint = 4096; - m.zones.push_back(z); - // A second zone with NO overrides proves the optional tail is per-record. - m.zones.push_back(zone("kick", 0, 23)); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 2); - CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() && - *back.zones[0].rootOverride == 64); - CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() && - back.zones[0].loopOverride->hasLoop && - back.zones[0].loopOverride->start == 12345 && - back.zones[0].loopOverride->end == 67890); - CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() && - *back.zones[0].startPoint == 4096); - // Zone 1: no overrides -> all optionals absent after round trip. - CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value()); - CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value()); -} - -static void testPerformanceStateV1PayloadBackCompat() { - // A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records) - // parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact - // shipped shape to prove the reader still accepts the marker-less payload. - std::vector b; - auto u32 = [&](std::uint32_t v) { - b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); - b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); - }; - u32(2); // envelope version 2 - u32(1); // zone count 1 (NOT the marker -> payload v1) - const std::string id = "legacy"; - u32(static_cast(id.size())); - b.insert(b.end(), id.begin(), id.end()); - u32(10); // lowNote - u32(40); // highNote - b.push_back(0); // hasRootOverride = 0 (record ends here in v1) - const PerformanceMap back = deserializePerformance(b, 44100.0); - CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy"); - CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40); - CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value()); - CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value()); -} - -static void testComponentStateLoopStartRoundTrip() { - // The overrides also round-trip through the v3 ComponentState envelope (zones nest inside - // it), so the processor's live getState/setState preserves them — the composition property. - ComponentState s; - s.selectionId = "pick"; - PerformanceZone z = zone("pick", 0, 127); - SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000; - z.loopOverride = lp; - z.startPoint = 128; - s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() && - back.map.zones[0].loopOverride->start == 500 && - back.map.zones[0].loopOverride->end == 9000); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() && - *back.map.zones[0].startPoint == 128); -} - -static void testPerformanceStateV1BackCompat() { - // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. - const std::vector v1 = serializeSelection("legacy-sample-id"); - const PerformanceMap back = deserializePerformance(v1, 44100.0); - CHECK(back.zones.size() == 1); - CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy-sample-id"); - CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 0 && back.zones[0].highNote == 127); - CHECK(back.zones.size() == 1 && !back.zones[0].rootOverride.has_value()); - // A v1 blob with an EMPTY id lifts to an empty map (no zone for "no selection"). - CHECK(deserializePerformance(serializeSelection(""), 44100.0).zones.empty()); -} - -static void testPerformanceStateGarbage() { - // Unknown version / truncated / empty -> empty map (never throws). - CHECK(deserializePerformance({}, 44100.0).zones.empty()); - CHECK(deserializePerformance({0xAA, 0xBB, 0xCC, 0xDD}, 44100.0).zones.empty()); // unknown version - // Truncated mid-zone: valid v2 header claiming 1 zone but no zone bytes -> empty. - std::vector t; - t.push_back(2); t.push_back(0); t.push_back(0); t.push_back(0); // version 2 - t.push_back(1); t.push_back(0); t.push_back(0); t.push_back(0); // count 1 - // (no zone payload) - CHECK(deserializePerformance(t, 44100.0).zones.empty()); -} - -static void testPerformanceStateNegativeNotesRoundTrip() { - // Notes are clamped in the UI, but the wire format must survive the full int range so - // a hand-set/legacy value round-trips without corruption (two's-complement on the wire). - PerformanceMap m; - m.zones.push_back(zone("s", 0, 127, /*override=*/0)); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1 && back.zones[0].rootOverride.has_value() && - *back.zones[0].rootOverride == 0); -} - -// --- Combined component state (v3, S10) -------------------------------------- - -static void testComponentStateRoundTrip() { - // The v3 state carries the single-capture selection AND the opt-in zones, distinctly. - ComponentState s; - s.selectionId = "picked-capture"; - s.map.zones.push_back(zone("z0", 0, 59, /*override=*/std::nullopt)); - s.map.zones.push_back(zone("z1", 60, 127, /*override=*/48)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId == "picked-capture"); - CHECK(back.map.zones.size() == 2); - CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && - back.map.zones[0].highNote == 59 && !back.map.zones[0].rootOverride.has_value()); - CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && - back.map.zones[1].rootOverride.has_value() && *back.map.zones[1].rootOverride == 48); -} - -static void testComponentStateSelectionOnlyNoZones() { - // A single-capture instance: a pick, no zones. Must restore the pick with an empty map - // (NOT synthesize a zone) — the default face is one capture, zones are opt-in. - ComponentState s; - s.selectionId = "just-a-pick"; - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId == "just-a-pick"); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateEmptyIsEmpty() { - // No pick, no zones -> restores EMPTY (the S10 silent empty state), never a first sample. - const ComponentState s; // selectionId "", empty map - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId.empty()); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV1BackCompat() { - // A v1 S4 blob (single-selection) lifts to {id, one full-keyboard zone} so an old pick - // survives as BOTH the selection and a one-zone map. - const std::vector v1 = serializeSelection("legacy-id"); - const ComponentState back = deserializeComponentState(v1, 44100.0); - CHECK(back.selectionId == "legacy-id"); - CHECK(back.map.zones.size() == 1); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "legacy-id" && - back.map.zones[0].lowNote == 0 && back.map.zones[0].highNote == 127); - // A v1 blob with an EMPTY id -> empty state (no selection, no zone). - const ComponentState empty = deserializeComponentState(serializeSelection(""), 44100.0); - CHECK(empty.selectionId.empty() && empty.map.zones.empty()); -} - -static void testComponentStateV2BackCompat() { - // A v2 S5 blob (zones-only) lifts to {"", zones}: that instance had zones but no separate - // single-capture selection. - PerformanceMap m; - m.zones.push_back(zone("s", 12, 24, /*override=*/std::nullopt)); - const std::vector v2 = serializePerformance(m); - const ComponentState back = deserializeComponentState(v2, 44100.0); - CHECK(back.selectionId.empty()); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "s" && - back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 24); -} - -static void testComponentStateGarbage() { - // Empty / unknown version -> empty (never throws across the host). - CHECK(deserializeComponentState({}, 44100.0).selectionId.empty()); - CHECK(deserializeComponentState({}, 44100.0).map.zones.empty()); - const std::vector unknown{0xAA, 0xBB, 0xCC, 0xDD}; - CHECK(deserializeComponentState(unknown, 44100.0).map.zones.empty()); - CHECK(deserializeComponentState(unknown, 44100.0).selectionId.empty()); - // A v3 header claiming a longer id than the blob holds -> empty (bounded read). - std::vector t; - t.push_back(3); t.push_back(0); t.push_back(0); t.push_back(0); // version 3 - t.push_back(200); t.push_back(0); t.push_back(0); t.push_back(0); // id length 200 (absent) - CHECK(deserializeComponentState(t, 44100.0).selectionId.empty()); - CHECK(deserializeComponentState(t, 44100.0).map.zones.empty()); -} - // --- S7: extractChannel / decodeChannels (cross-mode channel policy) ---------- static void testExtractChannelStereo() { @@ -867,7 +440,7 @@ static void testExtractChannelClampsToLast() { static void testDecodeChannelsMonoModeDownmixes() { // MONO mode: a stereo source averages to one channel (the existing policy), framesR empty. const std::vector stereo{1.0f, 0.0f, 0.4f, 0.6f}; // frames (1,0) and (0.4,0.6) - const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000); + const DecodedPcm d = decodeChannels(stereo, 2, ChannelMode::Mono, 48000); CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.5) && approx(d.monoFrames[1], 0.5)); CHECK(d.framesR.empty()); // mono mode -> single channel CHECK(d.sampleRate == 48000); @@ -876,7 +449,7 @@ static void testDecodeChannelsMonoModeDownmixes() { static void testDecodeChannelsStereoModeStereoSource() { // STEREO mode + stereo source: channels taken as-is (L/R), both present + distinct. const std::vector stereo{0.1f, 0.9f, 0.2f, 0.8f}; - const DecodedZonePcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100); + const DecodedPcm d = decodeChannels(stereo, 2, ChannelMode::Stereo, 44100); CHECK(d.monoFrames.size() == 2 && approx(d.monoFrames[0], 0.1) && approx(d.monoFrames[1], 0.2)); CHECK(d.framesR.size() == 2 && approx(d.framesR[0], 0.9) && approx(d.framesR[1], 0.8)); } @@ -884,1247 +457,12 @@ static void testDecodeChannelsStereoModeStereoSource() { static void testDecodeChannelsStereoModeMonoSourceDualMono() { // STEREO mode + mono source: dual-mono — framesR duplicates channel 0 (centered, not silent). const std::vector mono{0.3f, 0.6f, 0.9f}; - const DecodedZonePcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100); + const DecodedPcm d = decodeChannels(mono, 1, ChannelMode::Stereo, 44100); CHECK(d.monoFrames.size() == 3); CHECK(d.framesR.size() == 3); for (std::size_t i = 0; i < 3; ++i) CHECK(approx(d.monoFrames[i], d.framesR[i])); // R == L } -// --- S7: buildTier0Keymap stereo threading ------------------------------------ - -static void testBuildKeymapStereoCarriesSecondChannel() { - const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}, {0.9f, 0.8f}); - CHECK(km.samples.size() == 1); - CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); - CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && - approx(km.samples[0].framesR[0], 0.9) && approx(km.samples[0].framesR[1], 0.8)); -} - -static void testBuildKeymapMonoWhenNoSecondChannel() { - // No framesR passed -> mono SampleData (byte-identical to the pre-S7 build). - const Keymap km = buildTier0Keymap({0.1f, 0.2f}, 48000, 60, SampleLoop{}); - CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); - CHECK(km.samples.size() == 1 && km.samples[0].framesR.empty()); -} - -static void testBuildKeymapDropsMismatchedSecondChannel() { - // A framesR whose length mismatches frames is dropped -> mono (a bad pair never half-plays). - const Keymap km = buildTier0Keymap({0.1f, 0.2f, 0.3f}, 48000, 60, SampleLoop{}, {0.9f}); - CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 1); -} - -static void testBuildZonedKeymapCarriesSecondChannel() { - // The zoned build threads each zone's framesR when it length-matches channel 0. - std::vector zones; - ResolvedZone z; z.lowNote = 0; z.highNote = 127; z.rootNote = 60; zones.push_back(z); - std::vector decoded; - DecodedZonePcm d; d.monoFrames = {0.1f, 0.2f}; d.sampleRate = 44100; d.framesR = {0.9f, 0.8f}; - decoded.push_back(d); - const Keymap km = buildZonedKeymap(zones, decoded); - CHECK(km.samples.size() == 1 && km.samples[0].channelCount() == 2); - CHECK(km.samples.size() == 1 && km.samples[0].framesR.size() == 2 && - approx(km.samples[0].framesR[1], 0.8)); -} - -// --- S7: component state v4 (channel mode) ------------------------------------ - -static void testComponentStateV4RoundTripStereo() { - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId == "pick"); - CHECK(back.channelMode == ChannelMode::Stereo); // mode round-trips - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); -} - -static void testComponentStateV4RoundTripMono() { - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Mono; - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.selectionId == "pick"); - CHECK(back.channelMode == ChannelMode::Mono); -} - -static void testComponentStateV4DefaultIsMono() { - // A default-constructed state serializes with mono and restores mono (preserves behavior). - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); - CHECK(back.channelMode == ChannelMode::Mono); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -static void testComponentStateV3LiftsToMono() { - // A pre-S7 v3 blob (selection + zones, no mode byte) lifts to channelMode = mono, with the - // selection and zones intact. Build a v3 blob by hand: tag 3, id length + id, zones payload. - std::vector v3; - v3.push_back(3); v3.push_back(0); v3.push_back(0); v3.push_back(0); // version 3 - const std::string id = "legacy"; - v3.push_back(static_cast(id.size())); v3.push_back(0); v3.push_back(0); v3.push_back(0); - v3.insert(v3.end(), id.begin(), id.end()); - v3.push_back(0); v3.push_back(0); v3.push_back(0); v3.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v3, 44100.0); - CHECK(back.selectionId == "legacy"); - CHECK(back.channelMode == ChannelMode::Mono); // pre-S7 default - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV1V2LiftToMono() { - // The older lifts (v1 single-selection, v2 zones-only) also default to mono under v4 read. - const ComponentState v1 = deserializeComponentState(serializeSelection("old"), 44100.0); - CHECK(v1.channelMode == ChannelMode::Mono && v1.selectionId == "old"); - PerformanceMap m; m.zones.push_back(zone("s", 12, 24)); - const ComponentState v2 = deserializeComponentState(serializePerformance(m), 44100.0); - CHECK(v2.channelMode == ChannelMode::Mono && v2.map.zones.size() == 1); -} - -static void testComponentStateV4TruncatedModeByte() { - // A v4 blob truncated right after the version tag (no mode byte) -> empty, mono default holds. - std::vector t{4, 0, 0, 0}; // version 4, nothing after - const ComponentState back = deserializeComponentState(t, 44100.0); - CHECK(back.channelMode == ChannelMode::Mono); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -static void testComponentStateV4StereoWithZoneOverridesRoundTrip() { - // The MERGE composition property (S7 v4 envelope x S11 v2 zones payload): a v4 blob carrying - // channelMode = STEREO AND zones with loopOverride + startPoint must round-trip ALL of it - // losslessly. The channel-mode byte lives on the envelope; the loop/start overrides live in - // the self-versioned zones payload — the two tracks are orthogonal, so both survive one - // serialize/deserialize. (V4RoundTripStereo covers mode with a bare zone; LoopStartRoundTrip - // covers overrides at the default mono mode; this asserts them TOGETHER.) - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - PerformanceZone z0 = zone("z0", 12, 48, /*override=*/36); - SampleLoop lp0; lp0.hasLoop = true; lp0.start = 500; lp0.end = 9000; - z0.loopOverride = lp0; - z0.startPoint = 128; - PerformanceZone z1 = zone("z1", 49, 127); // second zone: no overrides (mixed payload) - s.map.zones.push_back(z0); - s.map.zones.push_back(z1); - - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.channelMode == ChannelMode::Stereo); // envelope field survives - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 2); - CHECK(back.map.zones.size() == 2 && back.map.zones[0].sampleId == "z0" && - back.map.zones[0].lowNote == 12 && back.map.zones[0].highNote == 48); - CHECK(back.map.zones.size() == 2 && back.map.zones[0].rootOverride.has_value() && - *back.map.zones[0].rootOverride == 36); - CHECK(back.map.zones.size() == 2 && back.map.zones[0].loopOverride.has_value() && - back.map.zones[0].loopOverride->hasLoop && - back.map.zones[0].loopOverride->start == 500 && - back.map.zones[0].loopOverride->end == 9000); - CHECK(back.map.zones.size() == 2 && back.map.zones[0].startPoint.has_value() && - *back.map.zones[0].startPoint == 128); - // The override-free second zone stays override-free (the payload framing per zone is intact). - CHECK(back.map.zones.size() == 2 && back.map.zones[1].sampleId == "z1" && - !back.map.zones[1].loopOverride.has_value() && - !back.map.zones[1].startPoint.has_value()); -} - -// --- v5 component state: the S8/S9 last-consumed-assignment marker ------------- - -static void testComponentStateV5MarkerRoundTrip() { - // The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope - // alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field. - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX - s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); -} - -static void testComponentStateDefaultMarkerIsZero() { - // A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first - // assign (generation >= 1) must not be swallowed by a non-zero default. - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); - CHECK(back.lastConsumedAssignGeneration == 0); -} - -static void testComponentStateV4LiftsMarkerToZero() { - // A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift - // with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand - // (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves - // an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies. - std::vector v4; - v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4 - v4.push_back(1); // channel mode = stereo - const std::string id = "saved"; - v4.push_back(static_cast(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0); - v4.insert(v4.end(), id.begin(), id.end()); - v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v4, 44100.0); - CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0 - CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored - CHECK(back.selectionId == "saved"); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV5TruncatedMarker() { - // A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty, - // mono + marker 0 default holds (bounded read, never throws across the host). - std::vector t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes - const ComponentState back = deserializeComponentState(t, 44100.0); - CHECK(back.lastConsumedAssignGeneration == 0); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -// --- v6 component state: the S-VIEW-4 preview-trigger velocity ------------------ - -static void testComponentStatePreviewVelocityRoundTrip() { - // The preview velocity round-trips through the v6 envelope alongside selection + mode + marker - // + zones. A non-default value (not 64) proves the byte is actually read back, not defaulted. - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.lastConsumedAssignGeneration = 1700000123456LL; - s.previewVelocity = 111; // non-default - s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.previewVelocity == 111); // velocity survives - CHECK(back.channelMode == ChannelMode::Stereo); // envelope neighbours intact - CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); -} - -static void testComponentStateDefaultPreviewVelocityIsMid() { - // A default-constructed state carries the mid velocity default and round-trips it. - const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); - CHECK(back.previewVelocity == kPreviewVelocityDefault); - CHECK(kPreviewVelocityDefault == 64); -} - -static void testComponentStatePreviewVelocityExtremes() { - // The full MIDI velocity range round-trips: 1 (softest audible) and 127 (max) both survive the - // single-byte field without clamping or overflow. - for (std::uint8_t v : {std::uint8_t{1}, std::uint8_t{127}}) { - ComponentState s; - s.previewVelocity = v; - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.previewVelocity == v); - } -} - -static void testComponentStateV5LiftsVelocityToMid() { - // A GENUINE v5 blob (version tag 5: mode byte, 8-byte marker, then id + zones — NO velocity - // byte) must lift previewVelocity to kPreviewVelocityDefault, its mode/marker/selection/zones - // intact. Build it by hand (serializeComponentState now emits v6, so it cannot make a v5 blob). - // This proves an already-saved pre-S-VIEW-4 instance restores at the mid default. - std::vector v5; - v5.push_back(5); v5.push_back(0); v5.push_back(0); v5.push_back(0); // version 5 - v5.push_back(1); // channel mode = stereo - for (int i = 0; i < 8; ++i) v5.push_back(0); // marker = 0 - const std::string id = "saved"; - v5.push_back(static_cast(id.size())); v5.push_back(0); v5.push_back(0); v5.push_back(0); - v5.insert(v5.end(), id.begin(), id.end()); - v5.push_back(0); v5.push_back(0); v5.push_back(0); v5.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v5, 44100.0); - CHECK(back.previewVelocity == kPreviewVelocityDefault); // no velocity byte in v5 -> mid default - CHECK(back.channelMode == ChannelMode::Stereo); // v5 mode byte honored - CHECK(back.selectionId == "saved"); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV4LiftsVelocityToMid() { - // A pre-S8/S9 v4 blob (mode byte, then id + zones — no marker, no velocity) also lifts - // previewVelocity to the mid default. Proves the older-than-v5 lift path defaults the field too. - std::vector v4; - v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4 - v4.push_back(0); // channel mode = mono - v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // id length 0 - v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v4, 44100.0); - CHECK(back.previewVelocity == kPreviewVelocityDefault); -} - -static void testComponentStateV6TruncatedVelocity() { - // A v6 blob truncated inside the header before the velocity byte (mode + full marker present, - // velocity byte cut) -> empty, with the mid velocity default holding (bounded read, never throws). - std::vector t{6, 0, 0, 0, 1}; // version 6, mode byte - for (int i = 0; i < 8; ++i) t.push_back(0); // full marker, no velocity byte - const ComponentState back = deserializeComponentState(t, 44100.0); - CHECK(back.previewVelocity == kPreviewVelocityDefault); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -// --- v7 component state: the Phase S voice-system fields (count / mode / trigger) ------------- - -static void testComponentStateVoiceSystemRoundTrip() { - // Non-default values on all three fields prove the bytes are read back, not defaulted; the - // envelope neighbours (mode, marker, velocity, selection, zones) ride alongside intact. - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.lastConsumedAssignGeneration = 42; - s.previewVelocity = 99; - s.voiceCount = 5; - s.voiceMode = VoiceMode::Mono; - s.monoTrigger = MonoTrigger::Legato; - s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.voiceCount == 5); - CHECK(back.voiceMode == VoiceMode::Mono); - CHECK(back.monoTrigger == MonoTrigger::Legato); - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(back.lastConsumedAssignGeneration == 42); - CHECK(back.previewVelocity == 99); - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); -} - -static void testComponentStateVoiceDefaultsRoundTrip() { - // A default-constructed state carries {16, Poly, Retrigger} — the pre-Phase-S behavior — - // and round-trips it. Locks the constants the engine + editor share. - const ComponentState back = - deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); - CHECK(back.voiceCount == kDefaultVoiceCount); - CHECK(kDefaultVoiceCount == 16 && kMinVoiceCount == 1 && kMaxVoiceCount == 32); - CHECK(back.voiceMode == VoiceMode::Poly); - CHECK(back.monoTrigger == MonoTrigger::Retrigger); -} - -static void testComponentStateVoiceCountExtremesRoundTrip() { - // Both range edges survive the single-byte field exactly. - for (int vc : {kMinVoiceCount, kMaxVoiceCount}) { - ComponentState s; - s.voiceCount = vc; - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.voiceCount == vc); - } -} - -static void testComponentStateVoiceCountWriterClamps() { - // The WRITER never emits an out-of-range byte: above-max clamps to max; a nonsensical - // below-min value (a programming error upstream) falls back to the default. - ComponentState hi; - hi.voiceCount = 99; - CHECK(deserializeComponentState(serializeComponentState(hi), 44100.0).voiceCount == - kMaxVoiceCount); - ComponentState lo; - lo.voiceCount = 0; - CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).voiceCount == - kDefaultVoiceCount); -} - -static void testComponentStateV6LiftsVoiceDefaults() { - // A GENUINE v6 blob (version tag 6: mode, marker, velocity, id, zones — NO voice bytes) - // lifts to the Phase S voice defaults {16, Poly, Retrigger}, its other fields intact. - // Hand-built (serializeComponentState now emits v7, so it cannot make a v6 blob). This - // proves an already-saved pre-Phase-S instance restores playing exactly as it did. - std::vector v6; - v6.push_back(6); v6.push_back(0); v6.push_back(0); v6.push_back(0); // version 6 - v6.push_back(1); // channel mode = stereo - for (int i = 0; i < 8; ++i) v6.push_back(0); // marker = 0 - v6.push_back(111); // preview velocity - const std::string id = "saved"; - v6.push_back(static_cast(id.size())); v6.push_back(0); v6.push_back(0); v6.push_back(0); - v6.insert(v6.end(), id.begin(), id.end()); - v6.push_back(0); v6.push_back(0); v6.push_back(0); v6.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v6, 44100.0); - CHECK(back.voiceCount == kDefaultVoiceCount); - CHECK(back.voiceMode == VoiceMode::Poly); - CHECK(back.monoTrigger == MonoTrigger::Retrigger); - CHECK(back.previewVelocity == 111); // the v6 byte still honored - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(back.selectionId == "saved"); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV7CorruptVoiceBytesFallBack() { - // Out-of-range voice bytes in a v7 blob fall back to each field's DEFAULT (the - // previewVelocity corrupt-byte precedent) — a corrupt blob never silences or distorts the - // instance to an edge the user never chose. Build v7 by serializing, then vandalize the - // three voice bytes in place (offsets: 4 version + 1 mode + 8 marker + 1 velocity = 14). - ComponentState s; - s.voiceCount = 7; - s.voiceMode = VoiceMode::Mono; - s.monoTrigger = MonoTrigger::Legato; - std::vector bytes = serializeComponentState(s); - bytes[14] = 0; // voice count 0: below kMinVoiceCount - bytes[15] = 7; // voice mode: not a legal {0,1} value - bytes[16] = 9; // mono trigger: not a legal {0,1} value - const ComponentState back = deserializeComponentState(bytes, 44100.0); - CHECK(back.voiceCount == kDefaultVoiceCount); - CHECK(back.voiceMode == VoiceMode::Poly); // non-1 mode byte -> Poly default - CHECK(back.monoTrigger == MonoTrigger::Retrigger); -} - -static void testComponentStateV7TruncatedVoiceBytes() { - // A v7 blob cut INSIDE the three voice bytes -> empty, defaults holding (bounded read). - std::vector t{7, 0, 0, 0, 1}; // version 7, mode byte - for (int i = 0; i < 8; ++i) t.push_back(0); // full marker - t.push_back(64); // velocity byte - t.push_back(16); // voice count only — - const ComponentState back = deserializeComponentState(t, 44100.0); // mode/trigger cut - CHECK(back.voiceCount == kDefaultVoiceCount); - CHECK(back.voiceMode == VoiceMode::Poly); - CHECK(back.monoTrigger == MonoTrigger::Retrigger); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -// --- v8 component state: the FB1 post-mixer master gain (linear double) ----------------------- - -static void testComponentStateMasterGainRoundTrip() { - // A non-default gain proves the bytes are read back, not defaulted; the envelope - // neighbours (voice bytes, velocity, selection, zones) ride alongside intact. - ComponentState s; - s.selectionId = "pick"; - s.previewVelocity = 99; - s.voiceCount = 5; - s.masterGainLinear = 0.25; // -12.04 dB - s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip - CHECK(back.voiceCount == 5); - CHECK(back.previewVelocity == 99); - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0"); -} - -static void testComponentStateMasterGainDefaultAndZeroRoundTrip() { - // Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips - // exactly — a user who pulled the gain to silence gets silence back after a save/load. - const ComponentState defBack = - deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0); - CHECK(defBack.masterGainLinear == 1.0); - ComponentState zero; - zero.masterGainLinear = 0.0; - const ComponentState zeroBack = - deserializeComponentState(serializeComponentState(zero), 44100.0); - CHECK(zeroBack.masterGainLinear == 0.0); -} - -static void testComponentStateMasterGainWriterClamps() { - // The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap; - // a negative/non-finite value (a programming error upstream) falls back to unity. - ComponentState hi; - hi.masterGainLinear = 1000.0; - CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0) - .masterGainLinear - - instrument::engine::masterGainMaxLinear()) < 1e-9); - ComponentState lo; - lo.masterGainLinear = -5.0; - CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear == - 1.0); -} - -static void testComponentStateV7LiftsUnityMasterGain() { - // A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO - // master-gain double) lifts to unity, its other fields intact. Hand-built - // (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an - // already-saved pre-FB1 instance restores playing at exactly its old output level. - std::vector v7; - v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7 - v7.push_back(1); // channel mode = stereo - for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0 - v7.push_back(111); // preview velocity - v7.push_back(5); // voice count - v7.push_back(1); // voice mode = mono - v7.push_back(1); // trigger = legato - const std::string id = "saved"; - v7.push_back(static_cast(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0); - v7.insert(v7.end(), id.begin(), id.end()); - v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v7, 44100.0); - CHECK(back.masterGainLinear == 1.0); - CHECK(back.voiceCount == 5); - CHECK(back.voiceMode == VoiceMode::Mono); - CHECK(back.monoTrigger == MonoTrigger::Legato); - CHECK(back.previewVelocity == 111); - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(back.selectionId == "saved"); - CHECK(back.map.zones.empty()); -} - -static void testComponentStateV8CorruptMasterGainFallsBack() { - // A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity - // corrupt-byte precedent) — never silences or blasts the instance. Build v8 by - // serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode + - // 8 marker + 1 velocity + 3 voice bytes = 17..24). - ComponentState s; - s.masterGainLinear = 0.5; - std::vector bytes = serializeComponentState(s); - for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern - const ComponentState back = deserializeComponentState(bytes, 44100.0); - CHECK(back.masterGainLinear == 1.0); -} - -static void testComponentStateV8TruncatedMasterGain() { - // A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read). - std::vector t{8, 0, 0, 0, 0}; // version 8, mode byte - for (int i = 0; i < 8; ++i) t.push_back(0); // full marker - t.push_back(64); // velocity byte - t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes - t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double - const ComponentState back = deserializeComponentState(t, 44100.0); - CHECK(back.masterGainLinear == 1.0); - CHECK(back.selectionId.empty() && back.map.zones.empty()); -} - -// --- v9 component state: the GA channel-mode explicit flag ------------------------------------ - -static void testComponentStateChannelModeExplicitRoundTrip() { - // The explicit flag survives a round-trip in BOTH states, its envelope neighbours intact. - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.channelModeExplicit = true; - s.masterGainLinear = 0.5; - ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.channelModeExplicit); - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12); - CHECK(back.selectionId == "pick"); - s.channelModeExplicit = false; - back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(!back.channelModeExplicit); // implicit round-trips too (not defaulted-true) - CHECK(back.channelMode == ChannelMode::Stereo); -} - -static void testComponentStateV8LiftsImplicitChannelMode() { - // A GENUINE v8 blob (version tag 8: mode, marker, velocity, voice bytes, gain, id, zones — - // NO explicit flag) lifts to channelModeExplicit = FALSE: a pre-GA mode byte is treated as - // the un-touched default so the shell's auto-default may follow the loaded capture. Hand- - // built (serializeComponentState now emits v9, so it cannot make a v8 blob). - std::vector v8; - v8.push_back(8); v8.push_back(0); v8.push_back(0); v8.push_back(0); // version 8 - v8.push_back(0); // channel mode = mono - for (int i = 0; i < 8; ++i) v8.push_back(0); // marker = 0 - v8.push_back(88); // preview velocity - v8.push_back(7); // voice count - v8.push_back(0); // voice mode = poly - v8.push_back(0); // trigger = retrigger - for (int i = 0; i < 8; ++i) v8.push_back(0); // gain double bytes... - v8[17 + 6] = 0xF0; v8[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754) - const std::string id = "saved"; - v8.push_back(static_cast(id.size())); v8.push_back(0); v8.push_back(0); v8.push_back(0); - v8.insert(v8.end(), id.begin(), id.end()); - v8.push_back(0); v8.push_back(0); v8.push_back(0); v8.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v8, 44100.0); - CHECK(!back.channelModeExplicit); // pre-GA blob -> implicit (auto-default allowed) - CHECK(back.channelMode == ChannelMode::Mono); - CHECK(back.masterGainLinear == 1.0); - CHECK(back.previewVelocity == 88); - CHECK(back.voiceCount == 7); - CHECK(back.selectionId == "saved"); - CHECK(back.map.zones.empty()); -} - -// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ---------------- -// -// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and -// ps-w9-t2-modes (payload v3, adds the per-zone play params) makes THREE combinations first -// reachable. Each pre-existing suite covers one axis in isolation; these lock the axes together. - -static void testV5EnvelopeWithMarkerAndPlayParamsRoundTrip() { - // (a) The full v5 face: channelMode + the S8/S9 consumed marker (envelope) AND zones carrying - // S15/S16 play params (payload) must ALL survive one serialize/deserialize. The two extensions - // sit on orthogonal tracks (envelope vs self-versioned payload); this proves they compose with - // no field cross-talk — neither the marker read nor the play-param read consumes the other's bytes. - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX -> exercises the full 8-byte field - PerformanceZone z = zone("z0", 0, 127, /*override=*/48); - z.play.playMode = PlayMode::Trigger; - z.play.adsr.holdSeconds = 0.093; - z.play.trigger.lengthFraction = 0.625; - z.play.trigger.fadeInFrames = 32; - z.play.trigger.fadeOutFrames = 96; - z.play.pitchEngine = PitchEngine::Varispeed; - z.play.pitchEnv.enabled = true; - z.play.pitchEnv.attackSeconds = 0.00018; - z.play.pitchEnv.decaySeconds = 0.0145; - z.play.pitchEnv.peakSemitones = 12.5; - s.map.zones.push_back(z); - - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.channelMode == ChannelMode::Stereo); // envelope: mode - CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // envelope: marker - CHECK(back.selectionId == "pick"); - CHECK(back.map.zones.size() == 1); - if (back.map.zones.size() != 1) return; - const ZonePlaySeconds& p = back.map.zones[0].play; // payload: play params - CHECK(p.playMode == PlayMode::Trigger); - CHECK(p.adsr.holdSeconds == 0.093); - CHECK(p.trigger.lengthFraction == 0.625); - CHECK(p.trigger.fadeInFrames == 32 && p.trigger.fadeOutFrames == 96); - CHECK(p.pitchEngine == PitchEngine::Varispeed); - CHECK(p.pitchEnv.enabled && p.pitchEnv.attackSeconds == 0.00018 && - p.pitchEnv.decaySeconds == 0.0145 && p.pitchEnv.peakSemitones == 12.5); -} - -// Hand-build ONE v3 zone record (marker-versioned payload body) for a single-zone map. Emits the -// exact on-wire order the header's PAYLOAD v3 spec + putZonesPayload write: id, lo/hi, no root/loop/ -// start overrides, then the always-present S15/S16 play tail. Used to synthesize the two v4 blobs -// below WITHOUT serializeComponentState (which now emits v5) — so the reader's widened accept-chain -// is exercised against a genuine, older-envelope byte layout rather than a self-produced buffer. -static std::vector handBuildV3PayloadOneZone(const std::string& id) { - std::vector b; - auto u32 = [&](std::uint32_t v) { - b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); - b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); - }; - auto u64 = [&](std::uint64_t v) { - for (int i = 0; i < 8; ++i) b.push_back(static_cast((v >> (i * 8)) & 0xFF)); - }; - auto dbl = [&](double d) { std::uint64_t bits; std::memcpy(&bits, &d, 8); u64(bits); }; - u32(kZonesFormatMarker); - u32(3); // PAYLOAD VERSION 3 (S15/S16 play tail present) - u32(1); // zone count 1 - u32(static_cast(id.size())); - b.insert(b.end(), id.begin(), id.end()); - u32(10); // lowNote - u32(70); // highNote - b.push_back(0); // hasRootOverride = 0 - b.push_back(0); // hasLoopOverride = 0 - b.push_back(0); // hasStartPoint = 0 - // Always-present v3 play tail: Trigger, hold, lengthFraction, fades, Varispeed, env off. - b.push_back(1); // playMode = Trigger - u64(static_cast(2048)); // adsr.holdFrames - dbl(0.5); // trigger.lengthFraction - u64(static_cast(16)); // trigger.fadeInFrames - u64(static_cast(48)); // trigger.fadeOutFrames - b.push_back(0); // pitchEngine = Varispeed - b.push_back(0); // pitchEnv.enabled = 0 - u64(0); // pitchEnv.attackFrames - u64(0); // pitchEnv.decayFrames - dbl(0.0); // pitchEnv.peakSemitones - return b; -} - -static void testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay() { - // (b) + (c) unified: a GENUINE v4 ENVELOPE blob (version tag 4: mode byte, id, then the zones - // payload — NO 8-byte marker) whose zones payload is PAYLOAD v3 (the exact shape an S15-test-build - // save produced). Under the widened accept-chain it must (b) lift lastConsumedAssignGeneration to - // 0 AND (c) deserialize its payload-v3 play params intact. This is the precise blob a user who - // saved on the S15 test build (envelope v4 + payload v3) would hold; the v4 lift branch delegates - // zones to readZonesPayload, which self-selects the v3 record shape from the payload marker — so - // the two v4 layouts (S7-era payload-v2, S15-era payload-v3) are UNAMBIGUOUS, distinguished - // inside the payload, not on the envelope. - std::vector v4; - v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // ENVELOPE version 4 - v4.push_back(1); // channel mode = stereo - const std::string id = "s15saved"; - v4.push_back(static_cast(id.size())); - v4.push_back(0); v4.push_back(0); v4.push_back(0); // idLen (LE) - v4.insert(v4.end(), id.begin(), id.end()); - const std::vector payload = handBuildV3PayloadOneZone("zv3"); - v4.insert(v4.end(), payload.begin(), payload.end()); - - const ComponentState back = deserializeComponentState(v4, 44100.0); - CHECK(back.lastConsumedAssignGeneration == 0); // (b) no marker in v4 -> default 0 - CHECK(back.channelMode == ChannelMode::Stereo); // v4 envelope mode honored - CHECK(back.selectionId == "s15saved"); - CHECK(back.map.zones.size() == 1); // (c) payload-v3 zone parsed under widened check - if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].sampleId == "zv3"); - CHECK(back.map.zones[0].lowNote == 10 && back.map.zones[0].highNote == 70); - const ZonePlaySeconds& p = back.map.zones[0].play; - CHECK(p.playMode == PlayMode::Trigger); // (c) play params survive the v4 envelope - // Legacy v3 wall-clock frames convert to seconds at the passed project rate (44100.0 here). - CHECK(approx(p.adsr.holdSeconds, 2048.0 / 44100.0)); - CHECK(p.trigger.lengthFraction == 0.5); - CHECK(p.trigger.fadeInFrames == 16 && p.trigger.fadeOutFrames == 48); // source frames, as-is - CHECK(p.pitchEngine == PitchEngine::Varispeed); - CHECK(p.pitchEnv.enabled == false); -} - -// --- S15/S16 zone-payload v3: per-zone play params round-trip + back-compat lift ------------- - -static void testPlayParamsRoundTrip() { - // A zone carrying explicit S15/S16 play params (Trigger mode, hold seconds, source-frame fades, - // Varispeed engine, pitch env on) must round-trip ALL fields losslessly through the v5 tail. - PerformanceMap m; - PerformanceZone z = zone("lead", 20, 100, /*override=*/55); - z.play.playMode = PlayMode::Trigger; - z.play.adsr.holdSeconds = 0.028; // wall-clock seconds - z.play.trigger.lengthFraction = 0.375; - z.play.trigger.fadeInFrames = 64; // source frames - z.play.trigger.fadeOutFrames = 128; - z.play.pitchEngine = PitchEngine::Varispeed; - z.play.pitchEnv.enabled = true; - z.play.pitchEnv.attackSeconds = 0.0002; // wall-clock seconds - z.play.pitchEnv.decaySeconds = 0.011; - z.play.pitchEnv.peakSemitones = -7.5; - m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - const ZonePlaySeconds& p = back.zones[0].play; - CHECK(p.playMode == PlayMode::Trigger); - CHECK(p.adsr.holdSeconds == 0.028); // exact double round-trip - CHECK(p.trigger.lengthFraction == 0.375); // exact double round-trip - CHECK(p.trigger.fadeInFrames == 64); - CHECK(p.trigger.fadeOutFrames == 128); - CHECK(p.pitchEngine == PitchEngine::Varispeed); - CHECK(p.pitchEnv.enabled == true); - CHECK(p.pitchEnv.attackSeconds == 0.0002); - CHECK(p.pitchEnv.decaySeconds == 0.011); - CHECK(p.pitchEnv.peakSemitones == -7.5); // exact double round-trip -} - -static void testPlayParamsComposeWithLoopStart() { - // S11 (loop/start) x S15/S16 (play params) tails co-exist per zone: both round-trip together. - PerformanceMap m; - PerformanceZone z = zone("pad", 0, 60); - SampleLoop lp; lp.hasLoop = true; lp.start = 111; lp.end = 222; - z.loopOverride = lp; - z.startPoint = 333; - z.play.playMode = PlayMode::Gate; - z.play.adsr.holdSeconds = 0.0225; - z.play.pitchEngine = PitchEngine::Preserve; - m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - CHECK(back.zones[0].loopOverride.has_value() && - back.zones[0].loopOverride->start == 111 && back.zones[0].loopOverride->end == 222); - CHECK(back.zones[0].startPoint.has_value() && *back.zones[0].startPoint == 333); - CHECK(back.zones[0].play.adsr.holdSeconds == 0.0225); - CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); -} - -// --- S-VIEW-6 key-tracking scalar: v6 round-trip + resolve-through + back-compat lift ---------- - -static void testKeyTrackRoundTrip() { - // A per-zone keyTrack survives the payload-v6 round trip losslessly (exact double). A second - // zone left at the default proves the field is per-record and the default is 1.0. - PerformanceMap m; - PerformanceZone z = zone("lead", 20, 100, /*override=*/55); - z.keyTrack = 0.5; - m.zones.push_back(z); - m.zones.push_back(zone("pad", 0, 19)); // default keyTrack (1.0) - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 2); - if (back.zones.size() != 2) return; - CHECK(back.zones[0].keyTrack == 0.5); // exact double round-trip - CHECK(back.zones[1].keyTrack == 1.0); // untouched zone keeps the 100% default -} - -static void testKeyTrackThroughComponentEnvelope() { - // keyTrack round-trips through the ComponentState envelope too (the composition property: - // the zones payload is envelope-independent, so it carries the v6 tail unchanged). - ComponentState s; - s.selectionId = "pick"; - PerformanceZone z = zone("pick", 0, 127); - z.keyTrack = 2.0; - s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.map.zones.size() == 1); - if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].keyTrack == 2.0); -} - -static void testKeyTrackResolvesToZone() { - // resolvePerformance carries keyTrack from the PerformanceZone through to the ResolvedZone, - // so the keymap build (and thus the repitch engine) sees the authored scalar. - const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); - PerformanceMap m; - PerformanceZone z = zone("a", 0, 127); - z.keyTrack = 0.0; // no tracking - m.zones.push_back(z); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1); - if (r.zones.size() != 1) return; - CHECK(r.zones[0].keyTrack == 0.0); -} - -static void testKeyTrackV5BackCompatLiftsToUnity() { - // A v5 PAYLOAD blob (marker + version 5 + full play tail but NO keyTrack field) lifts every - // zone to keyTrack == 1.0 (the PerformanceZone default) — so an instance saved BEFORE S-VIEW-6 - // repitches BIT-IDENTICALLY (100% ET). Hand-build the exact v5 record shape. - std::vector b; - auto u32 = [&](std::uint32_t v) { - b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); - b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); - }; - auto f64 = [&](double d) { - std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits)); - for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); - }; - auto i64 = [&](std::int64_t v) { - std::uint64_t bits = static_cast(v); - for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); - }; - u32(kPerformanceStateVersion); // envelope version (2) - u32(kZonesFormatMarker); // marker -> a versioned payload - u32(5); // PAYLOAD VERSION 5 (pre-S-VIEW-6, no keyTrack tail) - u32(1); // zone count 1 - const std::string id = "v5saved"; - u32(static_cast(id.size())); - b.insert(b.end(), id.begin(), id.end()); - u32(10); u32(70); // low/high - b.push_back(0); // hasRootOverride = 0 - b.push_back(0); // hasLoopOverride = 0 - b.push_back(0); // hasStartPoint = 0 - // v5 play tail (order matches putZonesPayload): playMode, hold, len, fadeIn, fadeOut, engine, - // envEnabled, envAttack, envDecay, peak, attack, decay, sustain, release. - b.push_back(0); // playMode = Gate - f64(0.0); // adsr.holdSeconds - f64(1.0); // trigger.lengthFraction - i64(0); i64(0); // trigger fades (source frames) - b.push_back(1); // pitchEngine = Preserve - b.push_back(0); // pitchEnv.enabled = false - f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak - f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R (tier-0 seconds) - const PerformanceMap back = deserializePerformance(b, 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - CHECK(back.zones[0].sampleId == "v5saved"); - CHECK(back.zones[0].keyTrack == 1.0); // no keyTrack tail -> default 1.0 (bit-identical repitch) -} - -// --- S-VIEW-9 velocity->amp curve: v7 round-trip + resolve-through + v6 back-compat lift --------- - -static void testVelocityCurveRoundTrip() { - // A per-zone velocity curve survives the payload-v7 round trip losslessly (exact point coords). - // A second zone left at the flat default proves the field is per-record and defaults to flat y=1. - PerformanceMap m; - PerformanceZone z = zone("lead", 20, 100); - z.velocityCurve = VelocityCurve::linear(); - z.velocityCurve.addPoint(60.0, 0.3); // an interior knot to exercise multi-point round-trip - m.zones.push_back(z); - m.zones.push_back(zone("pad", 0, 19)); // default flat curve - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 2); - if (back.zones.size() != 2) return; - CHECK(back.zones[0].velocityCurve.equals(z.velocityCurve)); // exact point round-trip - CHECK(back.zones[1].velocityCurve.equals(VelocityCurve::flat())); // default preserved - // And the flat default really is unity everywhere (R10-F1 Option A), not the old linear ramp. - CHECK(back.zones[1].velocityCurve.eval(1.0) == 1.0); - CHECK(back.zones[1].velocityCurve.eval(64.0) == 1.0); -} - -static void testVelocityCurveThroughComponentEnvelope() { - // The curve round-trips through the ComponentState envelope too (zones-payload is envelope- - // independent, so it carries the v7 tail unchanged). - ComponentState s; - s.selectionId = "pick"; - PerformanceZone z = zone("pick", 0, 127); - z.velocityCurve = VelocityCurve::linear(); - s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.map.zones.size() == 1); - if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].velocityCurve.equals(VelocityCurve::linear())); -} - -static void testVelocityCurveResolvesToZone() { - // resolvePerformance carries the curve from PerformanceZone through to ResolvedZone, so the - // keymap build (and thus the voice engine at start()) sees the authored curve. - const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); - PerformanceMap m; - PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = VelocityCurve::linear(); - m.zones.push_back(z); - const ResolvedPerformance r = resolvePerformance(json, m); - CHECK(r.zones.size() == 1); - if (r.zones.size() != 1) return; - CHECK(r.zones[0].velocityCurve.equals(VelocityCurve::linear())); -} - -// FA1 bug 3a — the COMPOSED end-to-end regression, mirroring the processor's reload composition -// exactly: an authored curve survives the component-state round-trip (the save/load seam), then -// resolvePerformance -> buildZonedKeymap -> VoiceEngine (constructed with a Preserve window, the -// DAW configuration) -> render, and the rendered level tracks velocity through the curve. This -// is the full pure slice of the click-to-sound path; only the bridge read + WAV decode (shell -// I/O) are outside it. A y=x curve at velocity 1 must be near-silent — NOT max volume. -static void testVelocityCurveEndToEndThroughReloadComposition() { - // 1. The instrument's own state: one full-keyboard zone with a LINEAR curve (the exact edit - // Daniel made), round-tripped through the v7 component-state wire (save -> load). - ComponentState s; - s.selectionId = "a"; - PerformanceZone z = zone("a", 0, 127); - z.velocityCurve = VelocityCurve::linear(); - s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 48000.0); - CHECK(back.map.zones.size() == 1); - if (back.map.zones.size() != 1) return; - - // 2. Resolve against a live bank blob (the shared bank_book parse, root 60 intrinsic). - const std::string json = bookJson({makeSample("a", "Kick", "reasampler_bank/a.wav", 60)}, {}); - const ResolvedPerformance rp = resolvePerformance(json, back.map); - CHECK(rp.zones.size() == 1); - if (rp.zones.size() != 1) return; - // The round-tripped zone still runs the PRESERVE product default (the DAW engine config). - CHECK(rp.zones[0].play.pitchEngine == PitchEngine::Preserve); - - // 3. Build the zoned keymap from decoded DC-1 PCM and play it through an engine constructed - // the way reloadInstrument constructs it (Preserve voices pre-sized to a real window). - auto steadyLevelAt = [&](int vel) -> double { - DecodedZonePcm pcm; - pcm.monoFrames.assign(4000, 1.0f); - pcm.sampleRate = 48000; - const Keymap km = buildZonedKeymap(rp.zones, {pcm}); - VoiceEngine eng(16, km, /*preserveCap=*/8, /*window=*/256); - eng.noteOn(62, vel); // transposed: the genuine OLA shifter path - std::vector out; - eng.render(out, 1000); - return static_cast(out[900]); // steady state (ring fully DC past the window) - }; - CHECK(approx(steadyLevelAt(127), 1.0)); - CHECK(approx(steadyLevelAt(64), 64.0 / 127.0)); - CHECK(steadyLevelAt(1) < 0.02); // velocity 1 through y=x: near-silent, never max volume -} - -static void testVelocityCurveV6BackCompatLiftsToFlat() { - // A v6 PAYLOAD blob (marker + version 6 + full play tail + keyTrack, but NO velocity-curve field) - // lifts every zone to VelocityCurve::flat() (R10-F1 Option A — flat y=1). This is the DELIBERATE - // non-back-compat behavior change: an instance saved BEFORE S-VIEW-9 now plays every velocity at - // unity, NOT the old linear velocity/127. Hand-build the exact v6 record shape. - std::vector b; - auto u32 = [&](std::uint32_t v) { - b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); - b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); - }; - auto f64 = [&](double d) { - std::uint64_t bits; std::memcpy(&bits, &d, sizeof(bits)); - for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); - }; - auto i64 = [&](std::int64_t v) { - std::uint64_t bits = static_cast(v); - for (int i = 0; i < 8; ++i) b.push_back(static_cast((bits >> (i * 8)) & 0xFF)); - }; - u32(kPerformanceStateVersion); // envelope version (2) - u32(kZonesFormatMarker); // marker -> a versioned payload - u32(6); // PAYLOAD VERSION 6 (pre-S-VIEW-9, keyTrack but no curve) - u32(1); // zone count 1 - const std::string id = "v6saved"; - u32(static_cast(id.size())); - b.insert(b.end(), id.begin(), id.end()); - u32(10); u32(70); // low/high - b.push_back(0); // hasRootOverride = 0 - b.push_back(0); // hasLoopOverride = 0 - b.push_back(0); // hasStartPoint = 0 - // v5 play tail. - b.push_back(0); // playMode = Gate - f64(0.0); // adsr.holdSeconds - f64(1.0); // trigger.lengthFraction - i64(0); i64(0); // trigger fades - b.push_back(1); // pitchEngine = Preserve - b.push_back(0); // pitchEnv.enabled = false - f64(0.0); f64(0.0); f64(0.0); // pitchEnv attack/decay/peak - f64(0.003); f64(0.0); f64(1.0); f64(0.060); // adsr A/D/S/R - f64(0.5); // v6 keyTrack (0.5) — present, but no curve tail follows - const PerformanceMap back = deserializePerformance(b, 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - CHECK(back.zones[0].sampleId == "v6saved"); - CHECK(back.zones[0].keyTrack == 0.5); // the v6 field still read correctly - // No curve tail -> flat y=1 default (the deliberate behavior change). - CHECK(back.zones[0].velocityCurve.equals(VelocityCurve::flat())); - CHECK(back.zones[0].velocityCurve.eval(20.0) == 1.0); // a soft hit now plays at unity -} - -static void testPlayParamsV2BackCompatLiftsToDefaults() { - // A pre-S15 PAYLOAD v2 blob (marker + version 2 + record with the S11 tail but NO play tail) - // lifts each zone to the PRODUCT defaults: Gate + Preserve (S16-F1) + no fades + env off — the - // deliberate behavior change for already-saved instruments. Hand-build a v2 record exactly. - std::vector b; - auto u32 = [&](std::uint32_t v) { - b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF); - b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF); - }; - u32(kPerformanceStateVersion); // envelope version (2) - u32(kZonesFormatMarker); // marker -> a versioned payload - u32(2); // PAYLOAD VERSION 2 (S11, no play tail) - u32(1); // zone count 1 - const std::string id = "old"; - u32(static_cast(id.size())); - b.insert(b.end(), id.begin(), id.end()); - u32(5); // lowNote - u32(80); // highNote - b.push_back(0); // hasRootOverride = 0 - b.push_back(0); // hasLoopOverride = 0 - b.push_back(0); // hasStartPoint = 0 (record ends here in v2) - const PerformanceMap back = deserializePerformance(b, 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - CHECK(back.zones[0].sampleId == "old"); - // Lifted to product defaults: Gate play mode, PRESERVE engine (the S16-F1 default), env off. - CHECK(back.zones[0].play.playMode == PlayMode::Gate); - CHECK(back.zones[0].play.pitchEngine == kDefaultPitchEngine); // == Preserve - CHECK(back.zones[0].play.pitchEnv.enabled == false); - CHECK(back.zones[0].play.adsr.holdSeconds == 0.0); -} - -static void testPlayParamsThroughComponentEnvelope() { - // The play params round-trip through the v4 COMPONENT envelope too (the composition property: - // the zones payload is envelope-independent, so v4 {channelMode, selection, zones} carries them). - ComponentState s; - s.selectionId = "pick"; - s.channelMode = ChannelMode::Stereo; - PerformanceZone z = zone("z", 0, 127); - z.play.playMode = PlayMode::Trigger; - z.play.trigger.lengthFraction = 0.9; - z.play.pitchEngine = PitchEngine::Varispeed; - s.map.zones.push_back(z); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.channelMode == ChannelMode::Stereo); - CHECK(back.map.zones.size() == 1); - if (back.map.zones.size() != 1) return; - CHECK(back.map.zones[0].play.playMode == PlayMode::Trigger); - CHECK(back.map.zones[0].play.trigger.lengthFraction == 0.9); - CHECK(back.map.zones[0].play.pitchEngine == PitchEngine::Varispeed); -} - -// --- S12 domain fix: wall-clock ADSR stored as SECONDS, resolved to frames at the live rate. --- -// -// These tests replace the R1/R2 flag/nominal-frame tests. The stored domain is seconds (rate-free); -// the keymap build resolves seconds -> frames against whatever WAV rate is live. The lift -> -// commit -> reload sequence must stay rate-correct at every rate (the R2 blocker). - -// All five AHDSR fields round-trip through the v5 payload as SECONDS (exact double round-trip). -static void testFullAdsrSecondsRoundTrip() { - PerformanceMap m; - PerformanceZone z = zone("pad", 0, 127); - z.play.playMode = PlayMode::Gate; - z.play.adsr.attackSeconds = 0.01; - z.play.adsr.holdSeconds = 0.02; - z.play.adsr.decaySeconds = 0.1; - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseSeconds = 0.2; - z.play.pitchEngine = PitchEngine::Preserve; - m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - const AdsrSeconds& a = back.zones[0].play.adsr; - CHECK(a.attackSeconds == 0.01); - CHECK(a.holdSeconds == 0.02); - CHECK(a.decaySeconds == 0.1); - CHECK(a.sustainLevel == 0.7); // exact double round-trip via bit-cast - CHECK(a.releaseSeconds == 0.2); - CHECK(back.zones[0].play.pitchEngine == PitchEngine::Preserve); -} - -// A legacy PAYLOAD v3 blob (Daniel's beta projects — has holdFrames but no A/D/S/R) lifts the -// absent A/D/S/R to the tier-0 SECONDS defaults (0.003 / 0 / 1.0 / 0.060), NO rate involved: they -// were always the seconds constants. holdSeconds converts from the v3 44.1k-nominal frame count. -static void testV3BlobLiftsAdsrToSecondsDefaults() { - std::vector blob; - auto u32 = [&](std::uint32_t v) { - blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); - blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); - }; - u32(kPerformanceStateVersion); // envelope version 2 header - const std::vector payload = handBuildV3PayloadOneZone("old"); - blob.insert(blob.end(), payload.begin(), payload.end()); - const PerformanceMap back = deserializePerformance(blob, 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - const AdsrSeconds& a = back.zones[0].play.adsr; - // hold converts from the v3 record's frames at the passed project rate (44100.0 here). - CHECK(approx(a.holdSeconds, 2048.0 / 44100.0)); // from the hand-built v3 record - CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); // 0.003 (tier-0 default seconds) - CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); // 0.0 - CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); // 1.0 - CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); // 0.060 -} - -// A legacy PAYLOAD v3 blob decoded at 96k: the hold frame count (2048) converts using the -// PASSED project rate, not a baked 44100 constant. At 96000 the seconds value is 2048/96000. -static void testV3BlobLiftsAdsrAt96k() { - std::vector blob; - auto u32 = [&](std::uint32_t v) { - blob.push_back(v & 0xFF); blob.push_back((v >> 8) & 0xFF); - blob.push_back((v >> 16) & 0xFF); blob.push_back((v >> 24) & 0xFF); - }; - u32(kPerformanceStateVersion); // envelope version 2 header - const std::vector payload = handBuildV3PayloadOneZone("old96k"); - blob.insert(blob.end(), payload.begin(), payload.end()); - const PerformanceMap back = deserializePerformance(blob, 96000.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) return; - const AdsrSeconds& a = back.zones[0].play.adsr; - // 2048 frames at 96000 Hz -> 2048/96000 seconds (not 2048/44100). - CHECK(approx(a.holdSeconds, 2048.0 / 96000.0)); - CHECK(a.attackSeconds == AdsrSeconds{}.attackSeconds); - CHECK(a.decaySeconds == AdsrSeconds{}.decaySeconds); - CHECK(a.sustainLevel == AdsrSeconds{}.sustainLevel); - CHECK(a.releaseSeconds == AdsrSeconds{}.releaseSeconds); -} - -// The lift -> commit -> reload sequence must stay rate-correct at 44.1k / 48k / 96k. A DEFAULT zone -// resolves to the tier-0 wall-clock durations at each rate (round(0.003*rate), round(0.060*rate)); -// an AUTHORED zone resolves to round(seconds*rate). This is the R2 blocker, pinned across rates. -static void testKeymapBuildResolvesSecondsToFramesAtEachRate() { - const auto rnd = [](double s, int rate) { - return static_cast(s * static_cast(rate) + 0.5); - }; - for (int rate : {44100, 48000, 96000}) { - // (a) DEFAULT zone (round-tripped through serialize/deserialize) -> tier-0 seconds. - { - PerformanceMap m; - m.zones.push_back(zone("def", 0, 127)); // product-default play (tier-0 AHDSR seconds) - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) continue; - ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; - rz.play = back.zones[0].play; - const DecodedZonePcm pcm{{0.5f}, rate}; - const Keymap km = buildZonedKeymap({rz}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) continue; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == rnd(0.003, rate)); // tier-0 attack at this rate - CHECK(a.decayFrames == 0); - CHECK(a.sustainLevel == 1.0); // level, never rate-scaled - CHECK(a.releaseFrames == rnd(0.060, rate)); // tier-0 release at this rate - } - // (b) AUTHORED zone -> round(seconds * rate) at this rate. - { - PerformanceMap m; - PerformanceZone z = zone("auth", 0, 127); - z.play.adsr.attackSeconds = 0.01; - z.play.adsr.decaySeconds = 0.1; - z.play.adsr.sustainLevel = 0.7; - z.play.adsr.releaseSeconds = 0.2; - m.zones.push_back(z); - const PerformanceMap back = deserializePerformance(serializePerformance(m), 44100.0); - CHECK(back.zones.size() == 1); - if (back.zones.size() != 1) continue; - ResolvedZone rz; rz.lowNote = 0; rz.highNote = 127; rz.rootNote = 60; - rz.play = back.zones[0].play; - const DecodedZonePcm pcm{{0.5f}, rate}; - const Keymap km = buildZonedKeymap({rz}, {pcm}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) continue; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == rnd(0.01, rate)); - CHECK(a.decayFrames == rnd(0.1, rate)); - CHECK(a.sustainLevel == 0.7); // level, never rate-scaled - CHECK(a.releaseFrames == rnd(0.2, rate)); - } - } -} - -// buildTier0Keymap resolves the default (seconds) play arg to frames at the WAV's rate — the -// single-capture fast path. At 48k the tier-0 attack is round(0.003*48000)=144, release -// round(0.060*48000)=2880 — identical wall-clock to any rate, no baked constant. -static void testBuildTier0KeymapResolvesSecondsAt48k() { - const Keymap km = buildTier0Keymap({0.5f}, 48000, 60, SampleLoop{}); - CHECK(km.samples.size() == 1); - if (km.samples.empty()) return; - const AdsrParams& a = km.samples[0].play.adsr; - CHECK(a.attackFrames == 144); // round(0.003 * 48000) - CHECK(a.decayFrames == 0); - CHECK(a.sustainLevel == 1.0); // level, not a time - CHECK(a.releaseFrames == 2880); // round(0.060 * 48000) -} - -// --- single-capture zone lifecycle: reconcileSingleCaptureZones (zone-bleed fix, 3a) --- - -// The bled state: two full-range Sample-face zones. Reconcile on load keeps only the -// selected sample's zone, params intact (a return to that sample restores its edits). -static void testReconcileKeepsOnlySelectedFullRangeZone() { - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - PerformanceZone b = zone("b", 0, 127); - b.keyTrack = 0.5; // distinctive param — must survive the reconcile - m.zones.push_back(b); - CHECK(reconcileSingleCaptureZones(m, "b")); - CHECK(m.zones.size() == 1); - CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "b"); - CHECK(m.zones.size() == 1 && m.zones[0].keyTrack == 0.5); -} - -// Loading a sample with no zone yet empties a Sample-face-shaped map — the shell then -// plays the selection via the Tier-0 fast path (product defaults), never the stale zone. -static void testReconcileClearsWhenSelectionUnzoned() { - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - CHECK(reconcileSingleCaptureZones(m, "b")); - CHECK(m.zones.empty()); -} - -// Any narrow key range marks Zone-view authorship: the map (including a legitimate -// full-range fallback zone) is untouched — first-match order is load-bearing there. -static void testReconcileLeavesAuthoredMapUntouched() { - PerformanceMap m; - m.zones.push_back(zone("a", 60, 72)); // authored narrow range - m.zones.push_back(zone("b", 0, 127)); // authored full-range fallback layer - CHECK(!reconcileSingleCaptureZones(m, "c")); - CHECK(m.zones.size() == 2); - CHECK(m.zones.size() == 2 && m.zones[0].sampleId == "a" && m.zones[1].sampleId == "b"); -} - -// A map already holding exactly the selection's one zone is coherent — no change reported, -// so callers do not republish/reload needlessly. -static void testReconcileNoOpWhenAlreadyCoherent() { - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - CHECK(!reconcileSingleCaptureZones(m, "a")); - CHECK(m.zones.size() == 1 && m.zones[0].sampleId == "a"); -} - -// Guards: an empty map and an empty selection both leave the map untouched. -static void testReconcileGuards() { - PerformanceMap empty; - CHECK(!reconcileSingleCaptureZones(empty, "a")); - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); - m.zones.push_back(zone("b", 0, 127)); - CHECK(!reconcileSingleCaptureZones(m, "")); // no selection -> never mutate - CHECK(m.zones.size() == 2); -} - -// The reported browse sequence, end to end at the pure layer: edit sample A (full-range -// zone materialized), browse-load B, edit B (zone appended AFTER A's). First proves the -// bug — first-match resolve plays A's zone while B is loaded — then proves the reconcile -// at the load step makes the loaded sample's zone the one resolve() returns. -static void testReconcileBrowseSequenceNoShadowing() { - const std::string json = bookJson( - {makeSample("a", "A", "reasampler_bank/a.wav", 60), - makeSample("b", "B", "reasampler_bank/b.wav", 60)}, {}); - - PerformanceMap m; - m.zones.push_back(zone("a", 0, 127)); // edit on A materializes A's zone - m.zones.push_back(zone("b", 0, 127)); // browse to B (pre-fix: no reconcile) + edit B - - // PCM markers: A decodes to 0.75, B to 0.25 — which zone resolve() picked is audible - // in frames[0] of the resolved sample. - const auto keymapFor = [&](const PerformanceMap& map) { - const ResolvedPerformance r = resolvePerformance(json, map); - std::vector decoded; - for (const ResolvedZone& rz : r.zones) { - decoded.push_back(DecodedZonePcm{ - {rz.relativePath == "reasampler_bank/a.wav" ? 0.75f : 0.25f}, 44100}); - } - return buildZonedKeymap(r.zones, decoded); - }; - - // The bled map: the engine resolves A's zone (index 0) — the shadowing bug. - const Keymap bled = keymapFor(m); - CHECK(bled.zones.size() == 2); - const ZoneResolution shadow = bled.resolve(60, 100); - CHECK(shadow.matched); - CHECK(shadow.matched && - bled.samples[bled.zones[shadow.zoneIndex].sampleIndex].frames[0] == 0.75f); - - // The fix at the load step: reconcile on the selection change keeps only B's zone — - // the loaded sample's zone IS the zone resolve() returns, at every note. - CHECK(reconcileSingleCaptureZones(m, "b")); - const Keymap fixed = keymapFor(m); - CHECK(fixed.zones.size() == 1); - for (int note : {0, 60, 127}) { - const ZoneResolution r = fixed.resolve(note, 100); - CHECK(r.matched); - CHECK(r.matched && - fixed.samples[fixed.zones[r.zoneIndex].sampleIndex].frames[0] == 0.25f); - } -} - // --- pS self-contained playback: the instance-owned sample refs (envelope v10) --------------- static SampleRefEntry refEntry(const std::string& id, const std::string& rel, int root, @@ -2143,226 +481,24 @@ static SampleRefEntry refEntry(const std::string& id, const std::string& rel, in return e; } -static void testSampleRefsRoundTrip() { - // v10: the owned refs table round-trips — path + every decode intrinsic per entry — - // with the envelope neighbours (selection, zones, explicit flag, gain) intact. - ComponentState s; - s.selectionId = "kick"; - s.channelMode = ChannelMode::Stereo; - s.channelModeExplicit = true; - s.masterGainLinear = 0.5; - s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36, - /*hasLoop=*/true, 100, 500, /*channels=*/2, - /*name=*/"Kick Drum")); - s.sampleRefs.push_back(refEntry("pad", "reasampler_bank/pad.wav", 60, - /*hasLoop=*/false, 0, 0, /*channels=*/1)); - s.map.zones.push_back(zone("pad", 48, 72)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.sampleRefs.size() == 2); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].sampleId == "kick"); - CHECK(back.sampleRefs.size() == 2 && - back.sampleRefs[0].ref.relativePath == "reasampler_bank/kick.wav"); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.rootNote == 36); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.loop.hasLoop && - back.sampleRefs[0].ref.loop.start == 100 && back.sampleRefs[0].ref.loop.end == 500); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].ref.channelCount == 2); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[0].displayName == "Kick Drum"); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].sampleId == "pad"); - CHECK(back.sampleRefs.size() == 2 && !back.sampleRefs[1].ref.loop.hasLoop); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].ref.channelCount == 1); - CHECK(back.sampleRefs.size() == 2 && back.sampleRefs[1].displayName.empty()); - // Envelope neighbours undisturbed (the refs read consumed exactly its own bytes). - CHECK(back.selectionId == "kick"); - CHECK(back.map.zones.size() == 1); - CHECK(back.channelMode == ChannelMode::Stereo && back.channelModeExplicit); - CHECK(std::fabs(back.masterGainLinear - 0.5) < 1e-12); +static void testReferencedSampleIdsIsTheLoadedCapture() { + // One capture = at most one referenced id. An empty selection contributes nothing (no + // phantom "" id can reach the refs table). + const std::vector ids = referencedSampleIds("b"); + CHECK(ids.size() == 1); + CHECK(ids.size() == 1 && ids[0] == "b"); + CHECK(referencedSampleIds("").empty()); } -static void testSampleRefsResolvePlayableKeymapWithoutBank() { - // THE pS architecture correction, end to end in the pure domain: a restored blob - // carrying refs resolves to a PLAYABLE keymap with NO bank blob anywhere in the path — - // deserialize -> resolvePerformanceFromRefs -> buildZonedKeymap. This is the load path - // an instance takes when the extension has not loaded (or does not exist). - ComponentState s; - s.sampleRefs.push_back(refEntry("a", "b/a.wav", 36)); - s.sampleRefs.push_back(refEntry("b", "b/b.wav", 48)); - s.map.zones.push_back(zone("a", 36, 47)); - s.map.zones.push_back(zone("b", 48, 59, /*rootOverride=*/50)); - const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0); - const ResolvedPerformance r = resolvePerformanceFromRefs(back.sampleRefs, back.map); - CHECK(r.zones.size() == 2); - CHECK(r.droppedSampleIds.empty()); - CHECK(r.zones.size() == 2 && r.zones[0].relativePath == "b/a.wav"); - CHECK(r.zones.size() == 2 && r.zones[0].rootNote == 36); // ref intrinsic - CHECK(r.zones.size() == 2 && r.zones[1].rootNote == 50); // zone override beats intrinsic - std::vector decoded; - decoded.push_back(DecodedZonePcm{{0.1f, 0.2f}, 44100}); - decoded.push_back(DecodedZonePcm{{0.3f}, 44100}); - const Keymap km = buildZonedKeymap(r.zones, decoded); - CHECK(km.resolve(40, 100).matched && km.resolve(40, 100).zoneIndex == 0); - CHECK(km.resolve(52, 100).matched && km.resolve(52, 100).zoneIndex == 1); -} - -static void testResolveFromRefsMissingRefDrops() { - // MISSING-REF = defined no-play: a zone whose id has no ref (never copied, or a pre-v10 - // blob not yet lifted) drops cleanly + reports; the survivor still plays — the same - // shape as the bank path's stale-id policy. (The shell's missing-FILE no-play is the - // decode seam: an unreadable WAV yields empty PCM and the zone drops in - // buildZonedKeymap — see testBuildZonedKeymapDropsEmptyPcm.) +static void testFindRefLooksUpTheOwnedCopy() { SampleRefs refs; refs.push_back(refEntry("a", "b/a.wav", 36)); - PerformanceMap m; - m.zones.push_back(zone("a", 0, 59)); - m.zones.push_back(zone("ghost", 60, 127)); - const ResolvedPerformance r = resolvePerformanceFromRefs(refs, m); - CHECK(r.zones.size() == 1); - CHECK(r.zones.size() == 1 && r.zones[0].relativePath == "b/a.wav"); - CHECK(r.droppedSampleIds.size() == 1); - CHECK(r.droppedSampleIds.size() == 1 && r.droppedSampleIds[0] == "ghost"); -} - -static void testResolveFromRefsMatchesBankResolve() { - // The two resolution paths share ONE fold (foldZone): the same map resolved via the - // bank blob and via a refs table refreshed FROM that bank yields identical effective - // zones — the paths cannot drift. - Sample s1 = makeSample("a", "Pad", "b/a.wav", 40); - s1.loop = LoopPoints{200, 800}; - const std::string json = bookJson({s1}, {}); - PerformanceMap m; - PerformanceZone z = zone("a", 10, 90, /*rootOverride=*/72); - z.startPoint = 512; - m.zones.push_back(z); - SampleRefs refs; - refreshRefsFromBank(refs, json, referencedSampleIds("", m)); - const ResolvedPerformance viaBank = resolvePerformance(json, m); - const ResolvedPerformance viaRefs = resolvePerformanceFromRefs(refs, m); - CHECK(viaBank.zones.size() == 1 && viaRefs.zones.size() == 1); - if (viaBank.zones.size() == 1 && viaRefs.zones.size() == 1) { - CHECK(viaRefs.zones[0].relativePath == viaBank.zones[0].relativePath); - CHECK(viaRefs.zones[0].rootNote == viaBank.zones[0].rootNote); // 72 (override) - CHECK(viaRefs.zones[0].loop.hasLoop == viaBank.zones[0].loop.hasLoop); - CHECK(viaRefs.zones[0].loop.start == viaBank.zones[0].loop.start); // 200 (intrinsic) - CHECK(viaRefs.zones[0].loop.end == viaBank.zones[0].loop.end); - CHECK(viaRefs.zones[0].startFrame == viaBank.zones[0].startFrame); // 512 - } -} - -static void testComponentStateV9LiftsToEmptyRefs() { - // OLD-BLOB FALLBACK: a genuine v9 blob (no refs table) restores with an EMPTY table and - // every other field intact — the shell then lifts via the bridge-resolve path once the - // bank is readable and re-saves self-contained. Hand-built (serializeComponentState now - // emits v10, so it cannot make a v9 blob). - std::vector v9; - v9.push_back(9); v9.push_back(0); v9.push_back(0); v9.push_back(0); // version 9 - v9.push_back(0); // channel mode = mono - for (int i = 0; i < 8; ++i) v9.push_back(0); // marker = 0 - v9.push_back(88); // preview velocity - v9.push_back(7); // voice count - v9.push_back(0); // voice mode = poly - v9.push_back(0); // trigger = retrigger - for (int i = 0; i < 8; ++i) v9.push_back(0); // gain double bytes... - v9[17 + 6] = 0xF0; v9[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754) - v9.push_back(1); // explicit flag = true - const std::string id = "saved"; - v9.push_back(static_cast(id.size())); v9.push_back(0); v9.push_back(0); v9.push_back(0); - v9.insert(v9.end(), id.begin(), id.end()); - v9.push_back(0); v9.push_back(0); v9.push_back(0); v9.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v9, 44100.0); - CHECK(back.sampleRefs.empty()); // pre-pS blob -> empty table (bridge-resolve lift) - CHECK(back.selectionId == "saved"); - CHECK(back.channelModeExplicit); - CHECK(back.previewVelocity == 88); - CHECK(back.voiceCount == 7); - CHECK(back.masterGainLinear == 1.0); - CHECK(back.map.zones.empty()); -} - -static void testInstanceGuidRoundTripV11() { - // v11 (pS-usage): the minted publish identity round-trips with the envelope - // neighbours (refs table before it, selection/zones after it) intact — the guid - // read consumed exactly its own bytes. An empty guid (never published) is legal - // and round-trips empty. - ComponentState s; - s.selectionId = "kick"; - s.instanceGuid = "0123456789abcdef0123456789abcdef"; - s.sampleRefs.push_back(refEntry("kick", "reasampler_bank/kick.wav", 36)); - s.map.zones.push_back(zone("kick", 0, 127)); - const ComponentState back = - deserializeComponentState(serializeComponentState(s), 44100.0); - CHECK(back.instanceGuid == "0123456789abcdef0123456789abcdef"); - CHECK(back.sampleRefs.size() == 1); - CHECK(back.selectionId == "kick"); - CHECK(back.map.zones.size() == 1); - - ComponentState fresh; - fresh.selectionId = "s"; - const ComponentState freshBack = - deserializeComponentState(serializeComponentState(fresh), 44100.0); - CHECK(freshBack.instanceGuid.empty()); - CHECK(freshBack.selectionId == "s"); -} - -static void testComponentStateV10LiftsToEmptyGuid() { - // OLD-BLOB FALLBACK: a genuine v10 blob (refs table but no instance guid) restores - // with an EMPTY guid — the processor mints one on first publish — and every other - // field intact. Hand-built (serializeComponentState now emits v11, so it cannot - // make a v10 blob). - std::vector v10; - v10.push_back(10); v10.push_back(0); v10.push_back(0); v10.push_back(0); // version 10 - v10.push_back(0); // mode = mono - for (int i = 0; i < 8; ++i) v10.push_back(0); // marker = 0 - v10.push_back(88); // preview velocity - v10.push_back(7); // voice count - v10.push_back(0); // voice mode = poly - v10.push_back(0); // trigger = retrigger - for (int i = 0; i < 8; ++i) v10.push_back(0); // gain double bytes... - v10[17 + 6] = 0xF0; v10[17 + 7] = 0x3F; // ...= 1.0 (LE IEEE-754) - v10.push_back(1); // explicit flag = true - // The v10 refs table: ONE entry {id "a", path "b/a.wav", root 36, no loop, ch 0, no name}. - v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // ref count 1 - v10.push_back(1); v10.push_back(0); v10.push_back(0); v10.push_back(0); // id len 1 - v10.push_back('a'); - const std::string relPath = "b/a.wav"; - v10.push_back(static_cast(relPath.size())); - v10.push_back(0); v10.push_back(0); v10.push_back(0); - v10.insert(v10.end(), relPath.begin(), relPath.end()); - v10.push_back(36); v10.push_back(0); v10.push_back(0); v10.push_back(0); // root 36 - v10.push_back(0); // hasLoop = false - for (int i = 0; i < 16; ++i) v10.push_back(0); // loop start+end - v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // channels 0 - v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // name len 0 - // NO guid field (the v11 addition) — the selection id follows directly. - const std::string id = "saved"; - v10.push_back(static_cast(id.size())); - v10.push_back(0); v10.push_back(0); v10.push_back(0); - v10.insert(v10.end(), id.begin(), id.end()); - v10.push_back(0); v10.push_back(0); v10.push_back(0); v10.push_back(0); // zone count 0 - const ComponentState back = deserializeComponentState(v10, 44100.0); - CHECK(back.instanceGuid.empty()); // pre-v11 blob -> empty guid (minted on publish) - CHECK(back.sampleRefs.size() == 1); - CHECK(back.sampleRefs.size() == 1 && back.sampleRefs[0].sampleId == "a"); - CHECK(back.sampleRefs.size() == 1 && - back.sampleRefs[0].ref.relativePath == "b/a.wav"); - CHECK(back.selectionId == "saved"); - CHECK(back.channelModeExplicit); - CHECK(back.previewVelocity == 88); - CHECK(back.voiceCount == 7); - CHECK(back.map.zones.empty()); -} - -static void testReferencedSampleIdsDedup() { - // Selection first, then map order, duplicates collapsed; an empty selection contributes - // nothing (no phantom "" id in the refs table). - PerformanceMap m; - m.zones.push_back(zone("a", 0, 59)); - m.zones.push_back(zone("b", 60, 99)); - m.zones.push_back(zone("a", 100, 127)); // duplicate id across zones - const std::vector ids = referencedSampleIds("b", m); // selection dups a zone - CHECK(ids.size() == 2); - CHECK(ids.size() == 2 && ids[0] == "b" && ids[1] == "a"); - const std::vector noSel = referencedSampleIds("", m); - CHECK(noSel.size() == 2); - CHECK(noSel.size() == 2 && noSel[0] == "a" && noSel[1] == "b"); + refs.push_back(refEntry("b", "b/b.wav", 48)); + const SelectedSample* a = findRef(refs, "a"); + CHECK(a != nullptr && a->rootNote == 36 && a->relativePath == "b/a.wav"); + CHECK(findRef(refs, "ghost") == nullptr); + CHECK(findRef(refs, "") == nullptr); // an empty id never matches an entry + CHECK(findRef(SampleRefs{}, "a") == nullptr); } static void testRefreshRefsFromBankUpsertAndOwnership() { @@ -2409,51 +545,6 @@ static void testRetainRefsFiltersToPlayedSet() { CHECK(refs.empty()); } -static void testSampleRefsTruncatedMidEntry() { - // A blob cut mid-refs-entry keeps the entries that parsed cleanly and restores the rest - // of the state empty (the selection/zones behind the cut are unreadable anyway) — the - // established truncation posture, never a throw across the host boundary. - 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 bytes = serializeComponentState(s); - // The tail after the refs table is idLen(4) + "kick"(4) + the empty-map zones payload - // (marker 4 + version 4 + count 4) = 20 bytes; entry 2 is 47 bytes (4+3 id, 4+7 path, - // 4 root, 1+8+8 loop, 4 channels, 4+0 name). Cutting 40 bytes lands 27 bytes into - // entry 2 (inside loop.start). - CHECK(bytes.size() > 40); - bytes.resize(bytes.size() - 40); - 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.map.zones.empty()); -} - -static void testSampleRefsReaderRangeFallbacks() { - // Corrupt-blob posture for the refs intrinsics (the refs table is the ONLY copy on the - // play path, so a bad field must degrade to its 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. - 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); -} - static void testLegacyLiftDecision() { // The #A terminating guard, pure: Retry while the blob is not readable YET (absent, // empty, malformed — the project's ext-state may simply not have parsed); Lift when a @@ -2472,6 +563,247 @@ static void testLegacyLiftDecision() { CHECK(legacyLiftDecision(json, {}) == LegacyLiftDecision::Stale); } +// --- resolvePlay: stored SECONDS -> engine FRAMES at the live rate -------------- + +static void testResolvePlayConvertsWallClockAtTheRate() { + // Wall-clock times convert at the LIVE rate; source-timeline quantities (the Trigger + // %-length and its fades) carry through untouched, and levels/depths are not times. + PlaySeconds st; + st.playMode = PlayMode::Trigger; + st.adsr.attackSeconds = 0.01; + st.adsr.holdSeconds = 0.05; + st.adsr.decaySeconds = 0.02; + st.adsr.sustainLevel = 0.8; + st.adsr.releaseSeconds = 0.15; + st.trigger.lengthFraction = 0.75; + st.trigger.fadeInFrames = 441; + st.trigger.fadeOutFrames = 882; + st.pitchEngine = PitchEngine::Preserve; + st.pitchEnv.enabled = true; + st.pitchEnv.attackSeconds = 0.02; + st.pitchEnv.decaySeconds = 0.03; + st.pitchEnv.peakSemitones = 5.0; + + const PlayParams at48 = resolvePlay(st, 48000); + CHECK(at48.playMode == PlayMode::Trigger); + CHECK(at48.adsr.attackFrames == 480); + CHECK(at48.adsr.holdFrames == 2400); + CHECK(at48.adsr.decayFrames == 960); + CHECK(at48.adsr.sustainLevel == 0.8); // a level, not a time + CHECK(at48.adsr.releaseFrames == 7200); + CHECK(at48.trigger.lengthFraction == 0.75); // source-timeline, unconverted + CHECK(at48.trigger.fadeInFrames == 441); + CHECK(at48.trigger.fadeOutFrames == 882); + CHECK(at48.pitchEngine == PitchEngine::Preserve); + CHECK(at48.pitchEnv.enabled); + CHECK(at48.pitchEnv.attackFrames == 960); + CHECK(at48.pitchEnv.decayFrames == 1440); + CHECK(at48.pitchEnv.peakSemitones == 5.0); // a depth, not a time + + // THE no-hardcoded-rate contract: the SAME stored seconds yield different frame counts + // at a different rate. A baked-in rate would make these equal. + const PlayParams at96 = resolvePlay(st, 96000); + CHECK(at96.adsr.attackFrames == 960); + CHECK(at96.adsr.holdFrames == 4800); + CHECK(at96.adsr.releaseFrames == 14400); + CHECK(at96.pitchEnv.attackFrames == 1920); + CHECK(at96.trigger.fadeInFrames == 441); // still unconverted +} + +static void testResolvePlayRoundsAndFloorsNegatives() { + PlaySeconds st; + st.adsr.attackSeconds = 0.0001; // 4.41 frames at 44.1k -> rounds to 4 + st.adsr.decaySeconds = 0.00012; // 5.292 -> rounds to 5 + st.adsr.releaseSeconds = -1.0; // negative is floored to 0, never a negative count + const PlayParams p = resolvePlay(st, 44100); + CHECK(p.adsr.attackFrames == 4); + CHECK(p.adsr.decayFrames == 5); + CHECK(p.adsr.releaseFrames == 0); +} + +// --- resolveCapture: the ONE override-beats-intrinsic fold --------------------- + +static SelectedSample ref(const std::string& rel, int root, bool hasLoop = false, + std::int64_t loopStart = 0, std::int64_t loopEnd = 0) { + SelectedSample s; + s.relativePath = rel; + s.rootNote = root; + s.loop.hasLoop = hasLoop; + s.loop.start = loopStart; + s.loop.end = loopEnd; + return s; +} + +static void testResolveCaptureUsesIntrinsicsWhenNoOverride() { + const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800), + InstrumentParams{}); + CHECK(r.relativePath == "b/a.wav"); + CHECK(r.rootNote == 40); // the capture's own root + CHECK(r.loop.hasLoop && r.loop.start == 200 && r.loop.end == 800); + CHECK(r.startFrame == 0); // absent start point -> frame 0 + CHECK(r.keyTrack == 1.0); +} + +static void testResolveCaptureOverridesBeatIntrinsics() { + InstrumentParams p; + p.rootOverride = 72; + SampleLoop lp; + lp.hasLoop = true; + lp.start = 10; + lp.end = 90; + p.loopOverride = lp; + p.startPoint = 512; + p.keyTrack = 0.5; + p.play.playMode = PlayMode::Trigger; + const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800), p); + CHECK(r.rootNote == 72); // override beats the intrinsic + CHECK(r.loop.hasLoop && r.loop.start == 10 && r.loop.end == 90); + CHECK(r.startFrame == 512); + CHECK(r.keyTrack == 0.5); + CHECK(r.play.playMode == PlayMode::Trigger); + CHECK(r.relativePath == "b/a.wav"); // the path is always the capture's +} + +static void testResolveCaptureLoopOverrideCanDisableTheLoop() { + // A loop override with hasLoop=false is how the user turns a looping capture into a + // one-shot — it must beat the intrinsic rather than falling back to it. + InstrumentParams p; + p.loopOverride = SampleLoop{}; // hasLoop == false + const ResolvedCapture r = resolveCapture(ref("b/a.wav", 40, true, 200, 800), p); + CHECK(!r.loop.hasLoop); +} + +static void testResolveFromBankAndRefsCannotDrift() { + // Both resolution paths share ONE fold, so the same parameter set resolved via the bank + // blob and via a refs table refreshed FROM that bank yields identical results. + Sample s1 = makeSample("a", "Pad", "b/a.wav", 40); + s1.loop = LoopPoints{200, 800}; + const std::string json = bookJson({s1}, {}); + InstrumentParams p; + p.rootOverride = 72; + p.startPoint = 512; + SampleRefs refs; + refreshRefsFromBank(refs, json, referencedSampleIds("a")); + + const std::optional viaBank = resolveFromBank(json, "a", p); + const std::optional viaRefs = resolveFromRefs(refs, "a", p); + CHECK(viaBank.has_value() && viaRefs.has_value()); + if (viaBank && viaRefs) { + CHECK(viaRefs->relativePath == viaBank->relativePath); + CHECK(viaRefs->rootNote == viaBank->rootNote); // 72 (override) + CHECK(viaRefs->loop.hasLoop == viaBank->loop.hasLoop); + CHECK(viaRefs->loop.start == viaBank->loop.start); // 200 (intrinsic) + CHECK(viaRefs->loop.end == viaBank->loop.end); + CHECK(viaRefs->startFrame == viaBank->startFrame); // 512 + } +} + +static void testResolveNoPickAndStaleIdAreSilence() { + const std::string json = bookJson({makeSample("a", "Kick", "b/a.wav", 36)}, {}); + SampleRefs refs; + refs.push_back(refEntry("a", "b/a.wav", 36)); + // No pick -> nothing to resolve; a stale id -> the SAME defined no-play, never a + // substituted first sample. + CHECK(!resolveFromBank(json, "", InstrumentParams{}).has_value()); + CHECK(!resolveFromBank(json, "ghost", InstrumentParams{}).has_value()); + CHECK(!resolveFromRefs(refs, "", InstrumentParams{}).has_value()); + CHECK(!resolveFromRefs(refs, "ghost", InstrumentParams{}).has_value()); + // And an unreadable bank blob resolves to nothing rather than throwing. + CHECK(!resolveFromBank("", "a", InstrumentParams{}).has_value()); + CHECK(!resolveFromBank("{garbage", "a", InstrumentParams{}).has_value()); +} + +static void testResolveFromRefsNeedsNoBankAtAll() { + // The self-contained play path: an instance with owned refs resolves with NO bank blob + // anywhere in the call — this is what an instance does when the extension is absent. + SampleRefs refs; + refs.push_back(refEntry("a", "b/a.wav", 36, /*hasLoop=*/true, 100, 500)); + const std::optional r = resolveFromRefs(refs, "a", InstrumentParams{}); + CHECK(r.has_value()); + CHECK(r && r->relativePath == "b/a.wav"); + CHECK(r && r->rootNote == 36); + CHECK(r && r->loop.hasLoop && r->loop.start == 100 && r->loop.end == 500); +} + +// --- buildSampleData ----------------------------------------------------------- + +static void testBuildSampleDataThreadsEverything() { + InstrumentParams p; + p.rootOverride = 40; + SampleLoop lp; + lp.hasLoop = true; + lp.start = 10; + lp.end = 90; + p.loopOverride = lp; + p.startPoint = 7; + p.keyTrack = 0.25; + p.play.adsr.attackSeconds = 0.01; + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p), + DecodedPcm{{0.1f, 0.2f, 0.3f}, 48000, {}}); + CHECK(sd.playable()); + CHECK(sd.frames.size() == 3); + CHECK(sd.sampleRate == 48000); + CHECK(sd.rootNote == 40); // the override, not the capture's 60 + CHECK(sd.loop.hasLoop && sd.loop.start == 10 && sd.loop.end == 90); + CHECK(sd.startFrame == 7); + CHECK(sd.keyTrack == 0.25); + CHECK(sd.channelCount() == 1); + // Wall-clock seconds resolve to frames at THIS decode's rate. + CHECK(sd.play.adsr.attackFrames == 480); +} + +static void testBuildSampleDataResolvesSecondsAtTheDecodeRate() { + // The rate that governs the conversion is the DECODE's, not a baked constant: the same + // parameter set built against two decodes yields two different frame counts. + InstrumentParams p; + p.play.adsr.attackSeconds = 0.1; + p.play.adsr.releaseSeconds = 0.25; + const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), p); + const SampleData at44 = buildSampleData(rc, DecodedPcm{{0.1f}, 44100, {}}); + const SampleData at96 = buildSampleData(rc, DecodedPcm{{0.1f}, 96000, {}}); + CHECK(at44.play.adsr.attackFrames == 4410); + CHECK(at44.play.adsr.releaseFrames == 11025); + CHECK(at96.play.adsr.attackFrames == 9600); + CHECK(at96.play.adsr.releaseFrames == 24000); +} + +static void testBuildSampleDataCarriesTheSecondChannel() { + const SampleData sd = buildSampleData( + resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), + DecodedPcm{{0.1f, 0.2f}, 44100, {0.9f, 0.8f}}); + CHECK(sd.channelCount() == 2); + CHECK(sd.framesR.size() == 2 && approx(sd.framesR[0], 0.9) && approx(sd.framesR[1], 0.8)); +} + +static void testBuildSampleDataDropsMismatchedSecondChannel() { + // A malformed pair must fall back to MONO rather than half-playing. + const SampleData sd = buildSampleData( + resolveCapture(ref("b/a.wav", 60), InstrumentParams{}), + DecodedPcm{{0.1f, 0.2f, 0.3f}, 44100, {0.9f}}); + CHECK(sd.channelCount() == 1); + CHECK(sd.framesR.empty()); +} + +static void testBuildSampleDataEmptyPcmIsUnplayable() { + // An unreadable/missing WAV decodes to empty PCM: the build yields an UNPLAYABLE + // SampleData (silence), never a voice started on an empty read span. (A non-positive + // rate is a programming error the build asserts on, so it is not exercised here.) + const ResolvedCapture rc = resolveCapture(ref("b/a.wav", 60), InstrumentParams{}); + const SampleData sd = buildSampleData(rc, DecodedPcm{{}, 44100, {}}); + CHECK(!sd.playable()); + CHECK(sd.frames.empty()); +} + +static void testBuildSampleDataCarriesTheVelocityCurve() { + InstrumentParams p; + p.velocityCurve = VelocityCurve::linear(); + const SampleData sd = buildSampleData(resolveCapture(ref("b/a.wav", 60), p), + DecodedPcm{{0.1f}, 44100, {}}); + // The curve reaches the engine's own copy: a mid velocity maps to ~half gain, which the + // flat default would not do. + CHECK(std::fabs(sd.velocityCurve.eval(64.0) - 64.0 / 127.0) < 1e-6); +} + int main() { testSelectByIdHit(); testSelectEmptyIdIsSilence(); @@ -2496,120 +828,36 @@ int main() { testDownmixStereoAverages(); testDownmixThreeChannelAverages(); testDownmixDegenerate(); - testBuildKeymapSingleFullZone(); testSelectionStateRoundTrip(); testSelectionStateEmptyId(); testSelectionStateWrongVersion(); testSelectionStateTruncated(); testWavTrimToDownmixPipelineStereo(); testWavTrimToDownmixPipelineMono(); - testResolveEmptyMap(); - testResolveEmptyBlob(); - testResolveMultiZoneAcrossBanks(); - testResolveStaleIdDropsZone(); - testResolveRootPrecedence(); - testResolveLoopThreaded(); - testResolveLoopOverrideWins(); - testResolveLoopOverrideDisablesLoop(); - testBuildZonedKeymapMultiZone(); - testBuildZonedKeymapThreadsLoopAndStart(); - testBuildZonedKeymapDropsEmptyPcm(); - testBuildZonedKeymapOverlapFirstWins(); - testBuildZonedKeymapEmpty(); - testPerformanceStateRoundTrip(); - testPerformanceStateEmpty(); - testPerformanceStateLoopStartRoundTrip(); - testPerformanceStateV1PayloadBackCompat(); - testPerformanceStateV1BackCompat(); - testPerformanceStateGarbage(); - testPerformanceStateNegativeNotesRoundTrip(); - testPlayParamsRoundTrip(); - testPlayParamsComposeWithLoopStart(); - testKeyTrackRoundTrip(); - testKeyTrackThroughComponentEnvelope(); - testKeyTrackResolvesToZone(); - testKeyTrackV5BackCompatLiftsToUnity(); - testVelocityCurveRoundTrip(); - testVelocityCurveThroughComponentEnvelope(); - testVelocityCurveResolvesToZone(); - testVelocityCurveEndToEndThroughReloadComposition(); - testVelocityCurveV6BackCompatLiftsToFlat(); - testPlayParamsV2BackCompatLiftsToDefaults(); - testPlayParamsThroughComponentEnvelope(); - testFullAdsrSecondsRoundTrip(); - testV3BlobLiftsAdsrToSecondsDefaults(); - testV3BlobLiftsAdsrAt96k(); - testKeymapBuildResolvesSecondsToFramesAtEachRate(); - testBuildTier0KeymapResolvesSecondsAt48k(); - testComponentStateRoundTrip(); - testComponentStateLoopStartRoundTrip(); - testComponentStateSelectionOnlyNoZones(); - testComponentStateEmptyIsEmpty(); - testComponentStateV1BackCompat(); - testComponentStateV2BackCompat(); - testComponentStateGarbage(); testExtractChannelStereo(); testExtractChannelClampsToLast(); testDecodeChannelsMonoModeDownmixes(); testDecodeChannelsStereoModeStereoSource(); testDecodeChannelsStereoModeMonoSourceDualMono(); - testBuildKeymapStereoCarriesSecondChannel(); - testBuildKeymapMonoWhenNoSecondChannel(); - testBuildKeymapDropsMismatchedSecondChannel(); - testBuildZonedKeymapCarriesSecondChannel(); - testComponentStateV4RoundTripStereo(); - testComponentStateV4RoundTripMono(); - testComponentStateV4DefaultIsMono(); - testComponentStateV3LiftsToMono(); - testComponentStateV1V2LiftToMono(); - testComponentStateV4TruncatedModeByte(); - testComponentStateV4StereoWithZoneOverridesRoundTrip(); - testComponentStateV5MarkerRoundTrip(); - testComponentStateDefaultMarkerIsZero(); - testComponentStateV4LiftsMarkerToZero(); - testComponentStateV5TruncatedMarker(); - testComponentStatePreviewVelocityRoundTrip(); - testComponentStateDefaultPreviewVelocityIsMid(); - testComponentStatePreviewVelocityExtremes(); - testComponentStateV5LiftsVelocityToMid(); - testComponentStateV4LiftsVelocityToMid(); - testComponentStateV6TruncatedVelocity(); - testComponentStateVoiceSystemRoundTrip(); - testComponentStateVoiceDefaultsRoundTrip(); - testComponentStateVoiceCountExtremesRoundTrip(); - testComponentStateVoiceCountWriterClamps(); - testComponentStateV6LiftsVoiceDefaults(); - testComponentStateV7CorruptVoiceBytesFallBack(); - testComponentStateV7TruncatedVoiceBytes(); - testComponentStateMasterGainRoundTrip(); - testComponentStateMasterGainDefaultAndZeroRoundTrip(); - testComponentStateMasterGainWriterClamps(); - testComponentStateV7LiftsUnityMasterGain(); - testComponentStateV8CorruptMasterGainFallsBack(); - testComponentStateV8TruncatedMasterGain(); - testComponentStateChannelModeExplicitRoundTrip(); - testComponentStateV8LiftsImplicitChannelMode(); - testV5EnvelopeWithMarkerAndPlayParamsRoundTrip(); - testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay(); - testReconcileKeepsOnlySelectedFullRangeZone(); - testReconcileClearsWhenSelectionUnzoned(); - testReconcileLeavesAuthoredMapUntouched(); - testReconcileNoOpWhenAlreadyCoherent(); - testReconcileGuards(); - testReconcileBrowseSequenceNoShadowing(); - testSampleRefsRoundTrip(); - testSampleRefsResolvePlayableKeymapWithoutBank(); - testResolveFromRefsMissingRefDrops(); - testResolveFromRefsMatchesBankResolve(); - testComponentStateV9LiftsToEmptyRefs(); - testInstanceGuidRoundTripV11(); - testComponentStateV10LiftsToEmptyGuid(); - testReferencedSampleIdsDedup(); + testReferencedSampleIdsIsTheLoadedCapture(); + testFindRefLooksUpTheOwnedCopy(); testRefreshRefsFromBankUpsertAndOwnership(); testRetainRefsFiltersToPlayedSet(); - testSampleRefsTruncatedMidEntry(); - testSampleRefsReaderRangeFallbacks(); testLegacyLiftDecision(); + testResolvePlayConvertsWallClockAtTheRate(); + testResolvePlayRoundsAndFloorsNegatives(); + testResolveCaptureUsesIntrinsicsWhenNoOverride(); + testResolveCaptureOverridesBeatIntrinsics(); + testResolveCaptureLoopOverrideCanDisableTheLoop(); + testResolveFromBankAndRefsCannotDrift(); + testResolveNoPickAndStaleIdAreSilence(); + testResolveFromRefsNeedsNoBankAtAll(); + testBuildSampleDataThreadsEverything(); + testBuildSampleDataResolvesSecondsAtTheDecodeRate(); + testBuildSampleDataCarriesTheSecondChannel(); + testBuildSampleDataDropsMismatchedSecondChannel(); + testBuildSampleDataEmptyPcmIsUnplayable(); + testBuildSampleDataCarriesTheVelocityCurve(); if (g_fail == 0) std::printf("sample_map: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index ec968c9..eaec933 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -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 #include @@ -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 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 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 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 out; @@ -247,8 +240,8 @@ static void testKeyTrackPreserveShiftCollapsesAtZero() { auto renderPreserve = [&](int note, double keyTrack) -> std::vector { 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(window)); eng.noteOn(note, 127); std::vector 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 out; @@ -633,7 +615,7 @@ static void testStartFrameOffsetsInitialRead() { for (int i = 0; i < 100; ++i) s.frames[i] = static_cast(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 out; @@ -649,7 +631,7 @@ static void testStartFrameZeroIsUnchanged() { s.frames.resize(20); for (int i = 0; i < 20; ++i) s.frames[i] = static_cast(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(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 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 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 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 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 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 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 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 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(kVelLow), 0.25}, + {static_cast(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 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 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 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 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 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 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 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 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& 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(w)); eng.noteOn(note, 127); std::vector out; @@ -2430,7 +2421,7 @@ static void testPreserveTailReleaseContinuous() { s.play.pitchEngine = PitchEngine::Preserve; s.play.adsr = flatAdsr(); s.play.adsr.releaseFrames = static_cast(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(w)); eng.noteOn(67, 127); std::vector 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(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(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(w)); eng.noteOn(72, 127); // +1 octave up-shift (tap sweeps the whole ring) std::vector 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();